@inglorious/capacitor 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -4,8 +4,9 @@
4
4
 
5
5
  Native project setup for [Capacitor](https://capacitorjs.com) apps, written once in `package.json` instead of clicked through Xcode and Android Studio. `@inglorious/create-app --mobile` sets it up for you.
6
6
 
7
- - `inglorious-capacitor configure` writes the app's native settings into `android/` and `ios/` after every `cap sync`: App Links and Universal Links, the signing team, push, Analytics off until consent, permissions and Info.plist entries, and the Firebase files.
7
+ - `inglorious-capacitor configure` writes the app's native settings into `android/` and `ios/` after every `cap sync`: App Links and Universal Links, the signing team, push, Analytics off until consent and without the advertising ID, permissions and Info.plist entries, and the Firebase files.
8
8
  - `inglorious-capacitor assets` makes every icon and splash screen, light and dark, from one logo.
9
+ - `inglorious-capacitor listing-assets` captures store screenshots of the running app in every size and language, and makes the promotional images (Google Play's feature graphic, a header and a search results card) and Google Play's 512px icon.
9
10
 
10
11
  ## Installation
11
12
 
@@ -21,7 +22,8 @@ Run `configure` as Capacitor's `sync` hook, so `cap add` and `cap sync` both app
21
22
  "capacitor:sync:after": "inglorious-capacitor configure",
22
23
  "mobile:init:ios": "cap add ios && inglorious-capacitor assets --ios",
23
24
  "mobile:init:android": "cap add android && inglorious-capacitor assets --android",
24
- "mobile:assets": "inglorious-capacitor assets"
25
+ "mobile:assets": "inglorious-capacitor assets",
26
+ "mobile:listing-assets": "inglorious-capacitor listing-assets"
25
27
  }
26
28
  }
27
29
  ```
@@ -63,20 +65,20 @@ The `native` key of `package.json` holds them all. Every key is optional; the ap
63
65
 
64
66
  ### What `configure` does
65
67
 
66
- | Setting | iOS | Android |
67
- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------ |
68
- | `appLinks` | Associated Domains entitlement | Verified intent filter for `paths` |
69
- | `appLinks` + `teamId` | `public/.well-known/apple-app-site-association` | |
70
- | `appLinks` + `sha256` | | `public/.well-known/assetlinks.json` |
71
- | `ios.teamId` | `DEVELOPMENT_TEAM`, so Xcode signs without asking | |
72
- | `ios.swipeBack` | A swipe from the left edge goes back: WebKit's back gesture, turned on in a generated `MainViewController` | |
73
- | `ios.infoPlist` | Info.plist entries | |
74
- | `android.permissions` | | `<uses-permission>` entries |
75
- | `@capacitor-firebase/messaging` installed | Push entitlement, background mode, AppDelegate methods | |
76
- | `@capacitor-firebase/analytics` installed | Collection off until consent | Collection off until consent |
77
- | `GoogleService-Info.plist` in the root | Copied into the app target | |
78
- | `google-services.json` in the root | | Copied to `android/app/` |
79
- | always | | Keystores kept out of git |
68
+ | Setting | iOS | Android |
69
+ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
70
+ | `appLinks` | Associated Domains entitlement | Verified intent filter for `paths` |
71
+ | `appLinks` + `teamId` | `public/.well-known/apple-app-site-association` | |
72
+ | `appLinks` + `sha256` | | `public/.well-known/assetlinks.json` |
73
+ | `ios.teamId` | `DEVELOPMENT_TEAM`, so Xcode signs without asking | |
74
+ | `ios.swipeBack` | A swipe from the left edge goes back: WebKit's back gesture, turned on in a generated `MainViewController` | |
75
+ | `ios.infoPlist` | Info.plist entries | |
76
+ | `android.permissions` | | `<uses-permission>` entries |
77
+ | `@capacitor-firebase/messaging` installed | Push entitlement, background mode, AppDelegate methods | |
78
+ | `@capacitor-firebase/analytics` installed | Collection off until consent | Collection off until consent, and no advertising ID permission |
79
+ | `GoogleService-Info.plist` in the root | Copied into the app target | |
80
+ | `google-services.json` in the root | | Copied to `android/app/` |
81
+ | always | | Keystores kept out of git |
80
82
 
81
83
  Path patterns end in `*` to match a prefix: `/jobs*` is every path that starts with `/jobs`. `exclude` takes paths and, starting with `#`, fragments; only iOS can exclude, so on Android list in `paths` only what opens the app. Serve both `.well-known` files as `application/json`.
82
84
 
@@ -86,6 +88,70 @@ It runs for the platform Capacitor is syncing, and changes nothing when the sett
86
88
 
87
89
  It centres the logo (`public/logo.svg` or `public/logo.png` by default) on `background` for the icons and the splash screen, and on `backgroundDark` for the dark splash screen, then hands the result to [`@capacitor/assets`](https://github.com/ionic-team/capacitor-assets) for every size and density. `iconScale` and `splashScale` are the logo's width as a share of the icon and of the splash screen. Pass `--ios` or `--android` for one platform.
88
90
 
91
+ ## Store listing images
92
+
93
+ `listing-assets` opens the running app in your installed Chrome (through `playwright-core`, so no browser download) and captures the screens listed in `listing.config.js`, once per device and language:
94
+
95
+ ```js
96
+ /** @type {import("@inglorious/capacitor").ListingConfig} */
97
+ export default {
98
+ url: "http://localhost:5173",
99
+ locales: ["en", "it"],
100
+ localeParam: "lang",
101
+ storage: { "app-analytics": { consent: "denied" } },
102
+
103
+ async signIn(page, { url }) {
104
+ await page.goto(`${url}/login`)
105
+ await page.fill('input[type="email"]', "demo@example.com")
106
+ await page.keyboard.press("Enter")
107
+ await page.fill('input[type="password"]', "demo-password")
108
+ await page.keyboard.press("Enter")
109
+ await page.waitForURL((address) => address.pathname !== "/login")
110
+ },
111
+
112
+ screens: [
113
+ { name: "01-feed", path: "/jobs" },
114
+ {
115
+ name: "02-new-job",
116
+ path: "/jobs/new",
117
+ prepare: (page) => page.getByLabel("Title").fill("Wedding in Puglia"),
118
+ },
119
+ ],
120
+
121
+ promo: {
122
+ tagline: { en: "Can't make it?\nPass it on.", it: "Non puoi?\nPassalo." },
123
+ subtitle: { en: "Jobs between colleagues.", it: "Lavori tra colleghi." },
124
+ accent: "#b4532a",
125
+ },
126
+ }
127
+ ```
128
+
129
+ It writes to `listing-assets/`:
130
+
131
+ - `<device>/<locale>/<screen>.png` for each device in `devices`:
132
+
133
+ | Device | Size | For |
134
+ | ------------ | --------- | ------------------------------------------- |
135
+ | `iphone-6.9` | 1320×2868 | App Store, required (default) |
136
+ | `iphone-6.3` | 1206×2622 | App Store, optional |
137
+ | `ipad-13` | 2064×2752 | App Store, required if the app runs on iPad |
138
+ | `android` | 1080×1920 | Google Play phones (default) |
139
+ | `tablet-7` | 1080×1920 | Google Play 7" tablets |
140
+ | `tablet-10` | 1440×2560 | Google Play 10" tablets |
141
+
142
+ - the promotional images for each locale, on `native.assets.background` unless `promo.background` says otherwise, with `promo.color`, `promo.font` and an optional `promo.accent` bar:
143
+ - `feature-graphic-<locale>.png`, 1024×500 (Google Play): the logo beside the app's name and `promo.tagline`.
144
+ - `header-<locale>.png`, 3840×1646: the logo, name and tagline in the middle 3:2 an iPhone shows, between two framed screenshots.
145
+ - `search-results-<locale>.png`, 1920×1280: the logo, name, tagline and `promo.subtitle` beside one framed screenshot.
146
+
147
+ The framed screenshots are `promo.screens` (the first two `screens` by default) from `promo.device` (the first iPhone by default).
148
+
149
+ - `icon-512.png`: Google Play's icon, made like the app icon.
150
+
151
+ `storage` sets `localStorage` before the app starts, so prompts such as analytics consent or push stay out of the shots; it can be a function of the locale. `localeParam` adds the language to each screen's address. If the app takes the language from the signed-in account instead, choose it in `signIn`. `prepare` brings a screen to the state worth showing, after it loads and settles for `settle` milliseconds (1000 by default).
152
+
153
+ Screenshots show real data, so use a demo account on a local backend with realistic seed data, never production.
154
+
89
155
  ## License
90
156
 
91
157
  **MIT License - Free and open source**
@@ -6,7 +6,9 @@ const USAGE = `Usage: inglorious-capacitor <command>
6
6
  configure Apply the "native" settings in package.json to the
7
7
  native projects (runs after every cap sync)
8
8
  assets [--ios] [--android]
9
- Make the app icons and splash screens from the logo`
9
+ Make the app icons and splash screens from the logo
10
+ listing-assets Capture store screenshots of the running app, and make
11
+ Google Play's feature graphic and icon (listing.config.js)`
10
12
 
11
13
  const FIRST_ARGUMENT = 2
12
14
  const SUCCESS = 0
@@ -22,6 +24,9 @@ try {
22
24
  } else if (command === "assets") {
23
25
  const { assets } = await import("../src/assets/index.js")
24
26
  await assets({ platforms: flags.map((flag) => flag.replace(/^--/, "")) })
27
+ } else if (command === "listing-assets") {
28
+ const { listingAssets } = await import("../src/listing-assets/index.js")
29
+ console.log(`Listing assets in ${await listingAssets()}`)
25
30
  } else {
26
31
  console.log(USAGE)
27
32
  process.exitCode = command ? FAILURE : SUCCESS
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inglorious/capacitor",
3
- "version": "0.1.0",
4
- "description": "Configures Capacitor's native projects from package.json, and makes their icons and splash screens from one logo.",
3
+ "version": "0.2.0",
4
+ "description": "Configures Capacitor's native projects from package.json, makes their icons and splash screens from one logo, and the screenshots and graphics the stores ask for.",
5
5
  "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -19,7 +19,8 @@
19
19
  "app-links",
20
20
  "icons",
21
21
  "splash-screen",
22
- "inglorious-web"
22
+ "inglorious-web",
23
+ "store-listing"
23
24
  ],
24
25
  "type": "module",
25
26
  "bin": {
@@ -42,6 +43,7 @@
42
43
  "dependencies": {
43
44
  "@capacitor/assets": "^4.0.0",
44
45
  "@trapezedev/project": "^7.1.11",
46
+ "playwright-core": "^1.65.0",
45
47
  "plist": "^3.1.1",
46
48
  "sharp": "^0.35.5"
47
49
  },
@@ -16,7 +16,7 @@ const SOURCES = path.join(
16
16
  "assets",
17
17
  )
18
18
  const PLATFORMS = ["ios", "android"]
19
- const DEFAULTS = {
19
+ export const ASSET_DEFAULTS = {
20
20
  background: "#ffffff",
21
21
  backgroundDark: "#121212",
22
22
  iconScale: 0.6,
@@ -37,14 +37,8 @@ const TRANSPARENT = "#00000000"
37
37
  */
38
38
  export async function assets({ root = process.cwd(), platforms = [] } = {}) {
39
39
  const settings = await readSettings(root, {})
40
- const options = { ...DEFAULTS, ...settings.assets }
41
- const logo =
42
- options.logo ?? LOGOS.find((file) => existsSync(path.join(root, file)))
43
- if (!logo || !existsSync(path.join(root, logo))) {
44
- throw new Error(
45
- `No logo found: add ${LOGOS.join(" or ")}, or set native.assets.logo`,
46
- )
47
- }
40
+ const options = { ...ASSET_DEFAULTS, ...settings.assets }
41
+ const logo = findLogo(root, options)
48
42
 
49
43
  const targets = (platforms.length ? platforms : PLATFORMS).filter(
50
44
  (platform) =>
@@ -59,7 +53,7 @@ export async function assets({ root = process.cwd(), platforms = [] } = {}) {
59
53
  const sources = path.join(root, SOURCES)
60
54
  rmSync(sources, { recursive: true, force: true })
61
55
  mkdirSync(sources, { recursive: true })
62
- await composeSources(readFileSync(path.join(root, logo)), sources, options)
56
+ await composeSources(readFileSync(logo), sources, options)
63
57
 
64
58
  execFileSync(
65
59
  process.execPath,
@@ -74,6 +68,22 @@ export async function assets({ root = process.cwd(), platforms = [] } = {}) {
74
68
  )
75
69
  }
76
70
 
71
+ /**
72
+ * @param {string} root
73
+ * @param {{ logo?: string }} options
74
+ * @returns {string} The logo's full path.
75
+ */
76
+ export function findLogo(root, { logo } = {}) {
77
+ const file =
78
+ logo ?? LOGOS.find((candidate) => existsSync(path.join(root, candidate)))
79
+ if (!file || !existsSync(path.join(root, file))) {
80
+ throw new Error(
81
+ `No logo found: add ${LOGOS.join(" or ")}, or set native.assets.logo`,
82
+ )
83
+ }
84
+ return path.join(root, file)
85
+ }
86
+
77
87
  /**
78
88
  * The sources `@capacitor/assets` expects, so it never scales the logo itself:
79
89
  * it would size the splash from the image's width, not the mark's.
@@ -11,12 +11,17 @@ const ANALYTICS_OFF = [
11
11
  "firebase_analytics_collection_enabled",
12
12
  "google_analytics_adid_collection_enabled",
13
13
  ]
14
+ const AD_ID_PERMISSIONS = [
15
+ "com.google.android.gms.permission.AD_ID",
16
+ "android.permission.ACCESS_ADSERVICES_AD_ID",
17
+ ]
18
+ const TOOLS_NAMESPACE = "http://schemas.android.com/tools"
14
19
  const GOOGLE_SERVICES = "google-services.json"
15
20
  const KEYSTORES = ["*.jks", "*.keystore"]
16
21
 
17
22
  /**
18
23
  * Applies the settings to the Android project: App Links, Analytics off until
19
- * consent, extra permissions, the Firebase file, and keystores kept out of git.
24
+ * consent and without the advertising ID, extra permissions, the Firebase file, and keystores kept out of git.
20
25
  *
21
26
  * @param {import("@trapezedev/project").MobileProject} project
22
27
  * @param {import("../../types/index.d.ts").ProjectSettings} settings
@@ -45,6 +50,7 @@ export function configureAndroid(project, settings) {
45
50
  `<meta-data android:name="${name}" android:value="false" />`,
46
51
  )
47
52
  }
53
+ removeAdIdPermissions(manifest)
48
54
  }
49
55
 
50
56
  for (const permission of settings.android.permissions) {
@@ -69,6 +75,23 @@ export function configureAndroid(project, settings) {
69
75
  ignoreKeystores(path.join(androidRoot, ".gitignore"))
70
76
  }
71
77
 
78
+ function removeAdIdPermissions(manifest) {
79
+ manifest.setAttrs(MANIFEST, { "xmlns:tools": TOOLS_NAMESPACE })
80
+ for (const permission of AD_ID_PERMISSIONS) {
81
+ if (
82
+ manifest.find(
83
+ `${MANIFEST}/uses-permission[@android:name='${permission}']`,
84
+ )?.length
85
+ ) {
86
+ continue
87
+ }
88
+ manifest.injectFragment(
89
+ MANIFEST,
90
+ `<uses-permission android:name="${permission}" tools:node="remove" />`,
91
+ )
92
+ }
93
+ }
94
+
72
95
  function appLinksFilter(appLinks) {
73
96
  const data = intentFilterData(appLinks)
74
97
  .map(
@@ -85,6 +85,15 @@ describe("configure", () => {
85
85
  expect(first.manifest).toContain(
86
86
  '<meta-data android:name="firebase_analytics_collection_enabled" android:value="false" />',
87
87
  )
88
+ expect(first.manifest).toContain(
89
+ 'xmlns:tools="http://schemas.android.com/tools"',
90
+ )
91
+ expect(first.manifest).toMatch(
92
+ /android:name="com\.google\.android\.gms\.permission\.AD_ID"[^>]*tools:node="remove"/,
93
+ )
94
+ expect(first.manifest).toMatch(
95
+ /android:name="android\.permission\.ACCESS_ADSERVICES_AD_ID"[^>]*tools:node="remove"/,
96
+ )
88
97
  expect(first.manifest).toContain(
89
98
  '<uses-permission android:name="android.permission.RECORD_AUDIO" />',
90
99
  )
@@ -128,6 +137,23 @@ describe("configure", () => {
128
137
  expect(entitlements).toContain("applinks:new.example.com")
129
138
  })
130
139
 
140
+ it("keeps the formatting of App Links files with the same content", async () => {
141
+ writePackage(root, {
142
+ native: {
143
+ appLinks: { domain: "app.example.com" },
144
+ android: { sha256: ["AA:BB"] },
145
+ },
146
+ })
147
+ await configure({ root, env: { CAPACITOR_CONFIG } })
148
+ const file = path.join(root, "public", ".well-known", "assetlinks.json")
149
+ const compact = JSON.stringify(JSON.parse(readFileSync(file, "utf8")))
150
+ writeFileSync(file, compact)
151
+
152
+ await configure({ root, env: { CAPACITOR_CONFIG } })
153
+
154
+ expect(readFileSync(file, "utf8")).toBe(compact)
155
+ })
156
+
131
157
  it("turns on iOS swipe back with a view controller of its own", async () => {
132
158
  writePackage(root, { native: { ios: { swipeBack: true } } })
133
159
 
@@ -81,8 +81,15 @@ function writeWellKnown(settings) {
81
81
  }
82
82
 
83
83
  function writeJson(file, value) {
84
- const source = JSON.stringify(value, null, INDENTATION) + EOL
85
- if (existsSync(file) && readFileSync(file, "utf8") === source) return
84
+ if (existsSync(file) && isSameJson(readFileSync(file, "utf8"), value)) return
86
85
  mkdirSync(path.dirname(file), { recursive: true })
87
- writeFileSync(file, source)
86
+ writeFileSync(file, JSON.stringify(value, null, INDENTATION) + EOL)
87
+ }
88
+
89
+ function isSameJson(source, value) {
90
+ try {
91
+ return JSON.stringify(JSON.parse(source)) === JSON.stringify(value)
92
+ } catch {
93
+ return false
94
+ }
88
95
  }
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { assets } from "./assets/index.js"
2
2
  export { readSettings } from "./config.js"
3
3
  export { configure } from "./configure/index.js"
4
+ export { listingAssets } from "./listing-assets/index.js"
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Store screenshot sizes, as a viewport in CSS pixels and a pixel ratio whose
3
+ * product is the size the store asks for.
4
+ */
5
+ export const DEVICES = {
6
+ "iphone-6.9": { viewport: { width: 440, height: 956 }, scale: 3 },
7
+ "iphone-6.3": { viewport: { width: 402, height: 874 }, scale: 3 },
8
+ "ipad-13": { viewport: { width: 1032, height: 1376 }, scale: 2 },
9
+ android: { viewport: { width: 360, height: 640 }, scale: 3 },
10
+ "tablet-7": { viewport: { width: 540, height: 960 }, scale: 2 },
11
+ "tablet-10": { viewport: { width: 720, height: 1280 }, scale: 2 },
12
+ }
13
+
14
+ export const DEFAULT_DEVICES = ["iphone-6.9", "android"]
15
+
16
+ /**
17
+ * @param {string[]} names
18
+ * @returns {[string, typeof DEVICES[keyof typeof DEVICES]][]}
19
+ */
20
+ export function resolveDevices(names = DEFAULT_DEVICES) {
21
+ const unknown = names.filter((name) => !(name in DEVICES))
22
+ if (unknown.length) {
23
+ throw new Error(
24
+ `Unknown devices: ${unknown.join(", ")}. Choose from ${Object.keys(DEVICES).join(", ")}`,
25
+ )
26
+ }
27
+ return names.map((name) => [name, DEVICES[name]])
28
+ }
29
+
30
+ /**
31
+ * The address of a screen in a locale: `localeParam` added to the path's own
32
+ * query, if the app reads the locale from links.
33
+ *
34
+ * @param {string} base
35
+ * @param {string} screenPath
36
+ * @param {{ locale?: string, localeParam?: string }} [options]
37
+ */
38
+ export function screenUrl(base, screenPath, { locale, localeParam } = {}) {
39
+ const url = new URL(screenPath, base)
40
+ if (locale && localeParam) url.searchParams.set(localeParam, locale)
41
+ return url.href
42
+ }
@@ -0,0 +1,220 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import process from "node:process"
4
+ import { pathToFileURL } from "node:url"
5
+
6
+ import sharp from "sharp"
7
+
8
+ import { ASSET_DEFAULTS, findLogo } from "../assets/index.js"
9
+ import { readSettings } from "../config.js"
10
+ import { resolveDevices, screenUrl } from "./devices.js"
11
+ import { PROMO_FORMATS, promoHtml } from "./promo.js"
12
+
13
+ const CONFIG_FILES = ["listing.config.js", "listing.config.mjs"]
14
+ const DEFAULTS = {
15
+ url: "http://localhost:5173",
16
+ out: "listing-assets",
17
+ browser: "chrome",
18
+ settle: 1000,
19
+ }
20
+ const PROMO_SHOTS = 2
21
+ const LANGUAGE_TIMEOUT = 5000
22
+ const FIRST = 0
23
+ const PLAY_ICON_SIZE = 512
24
+
25
+ /**
26
+ * Makes what the stores ask for besides the app: screenshots of the running
27
+ * app at each store size and in each locale, the promotional images (Google
28
+ * Play's feature graphic, a header and a search results card) in each locale,
29
+ * and Google Play's 512px icon. The screens, the locales and how to sign in
30
+ * come from `listing.config.js`; the logo and colours from `native.assets`.
31
+ *
32
+ * @param {{ root?: string }} [options]
33
+ * @returns {Promise<string>} The folder written to.
34
+ */
35
+ export async function listingAssets({ root = process.cwd() } = {}) {
36
+ const settings = await readSettings(root, {})
37
+ const config = { ...DEFAULTS, ...(await readListingConfig(root)) }
38
+ const assets = { ...ASSET_DEFAULTS, ...settings.assets }
39
+ const logo = findLogo(root, assets)
40
+ const out = path.join(root, config.out)
41
+ const locales = config.locales?.length ? config.locales : [null]
42
+ const devices = resolveDevices(config.devices)
43
+
44
+ await assertReachable(config.url)
45
+ mkdirSync(out, { recursive: true })
46
+
47
+ const { chromium } = await import("playwright-core")
48
+ const browser = await chromium.launch({ channel: config.browser })
49
+ try {
50
+ for (const locale of locales) {
51
+ let session
52
+ for (const [device, { viewport, scale }] of devices) {
53
+ const dir = path.join(out, device, locale ?? "")
54
+ mkdirSync(dir, { recursive: true })
55
+ const context = await browser.newContext({
56
+ viewport,
57
+ deviceScaleFactor: scale,
58
+ isMobile: true,
59
+ hasTouch: true,
60
+ ...(locale && { locale }),
61
+ ...(session && { storageState: session }),
62
+ })
63
+ await context.addInitScript(
64
+ storageScript,
65
+ storageEntries(config.storage, locale),
66
+ )
67
+ const page = await context.newPage()
68
+ if (config.signIn && !session) {
69
+ await config.signIn(page, { url: config.url, locale })
70
+ session = await context.storageState()
71
+ }
72
+ for (const screen of config.screens ?? []) {
73
+ await page.goto(
74
+ screenUrl(config.url, screen.path, {
75
+ locale,
76
+ localeParam: config.localeParam,
77
+ }),
78
+ )
79
+ await page.waitForLoadState("networkidle")
80
+ await page.waitForTimeout(config.settle)
81
+ if (locale) await assertLanguage(page, locale, screen.name)
82
+ if (screen.prepare) await screen.prepare(page, { locale })
83
+ await page.screenshot({ path: path.join(dir, `${screen.name}.png`) })
84
+ }
85
+ await context.close()
86
+ }
87
+ }
88
+
89
+ const promo = config.promo ?? {}
90
+ const promoDevice = promo.device ?? defaultPromoDevice(devices)
91
+ const promoScreens =
92
+ promo.screens ??
93
+ (config.screens ?? []).slice(FIRST, PROMO_SHOTS).map(({ name }) => name)
94
+ for (const locale of locales) {
95
+ const options = {
96
+ logo: dataUrl(logo),
97
+ name: settings.appName,
98
+ tagline: localized(promo.tagline, locale),
99
+ subtitle: localized(promo.subtitle, locale),
100
+ shots: promoScreens.map((name) =>
101
+ dataUrl(path.join(out, promoDevice, locale ?? "", `${name}.png`)),
102
+ ),
103
+ background: promo.background ?? assets.background,
104
+ color: promo.color,
105
+ accent: promo.accent,
106
+ font: promo.font,
107
+ }
108
+ for (const [format, { width, height, scale }] of Object.entries(
109
+ PROMO_FORMATS,
110
+ )) {
111
+ const page = await browser.newPage({
112
+ viewport: { width, height },
113
+ deviceScaleFactor: scale,
114
+ })
115
+ await page.setContent(promoHtml(format, options))
116
+ await page.waitForLoadState("load")
117
+ await page.screenshot({
118
+ path: path.join(
119
+ out,
120
+ locale ? `${format}-${locale}.png` : `${format}.png`,
121
+ ),
122
+ })
123
+ await page.close()
124
+ }
125
+ }
126
+ } finally {
127
+ await browser.close()
128
+ }
129
+
130
+ await playIcon(logo, assets, path.join(out, "icon-512.png"))
131
+ return out
132
+ }
133
+
134
+ async function assertLanguage(page, locale, screen) {
135
+ try {
136
+ await page.waitForFunction(
137
+ (expected) => {
138
+ const { lang } = globalThis.document.documentElement
139
+ return !lang || lang === expected || lang.startsWith(`${expected}-`)
140
+ },
141
+ locale,
142
+ { timeout: LANGUAGE_TIMEOUT },
143
+ )
144
+ } catch {
145
+ const lang = await page.evaluate(
146
+ () => globalThis.document.documentElement.lang,
147
+ )
148
+ throw new Error(
149
+ `${screen} shows "${lang}" instead of "${locale}": check localeParam, or choose the language in signIn`,
150
+ )
151
+ }
152
+ }
153
+
154
+ function defaultPromoDevice(devices) {
155
+ const [[first]] = devices
156
+ const [iphone] = devices.find(([name]) => name.startsWith("iphone")) ?? []
157
+ return iphone ?? first
158
+ }
159
+
160
+ async function readListingConfig(root) {
161
+ const file = CONFIG_FILES.map((name) => path.join(root, name)).find(
162
+ (candidate) => existsSync(candidate),
163
+ )
164
+ if (!file) {
165
+ throw new Error(
166
+ `No listing config found: add listing.config.js with the screens to capture`,
167
+ )
168
+ }
169
+ const { default: config } = await import(pathToFileURL(file).href)
170
+ return config
171
+ }
172
+
173
+ async function assertReachable(url) {
174
+ try {
175
+ await fetch(url)
176
+ } catch {
177
+ throw new Error(`Nothing answers at ${url}: start the app first`)
178
+ }
179
+ }
180
+
181
+ function storageEntries(storage, locale) {
182
+ const values = typeof storage === "function" ? storage(locale) : storage
183
+ return Object.entries(values ?? {}).map(([key, value]) => [
184
+ key,
185
+ typeof value === "string" ? value : JSON.stringify(value),
186
+ ])
187
+ }
188
+
189
+ function storageScript(entries) {
190
+ for (const [key, value] of entries) localStorage.setItem(key, value)
191
+ }
192
+
193
+ function localized(value, locale) {
194
+ if (value == null || typeof value === "string") return value ?? ""
195
+ const [first = ""] = Object.values(value)
196
+ return value[locale] ?? first
197
+ }
198
+
199
+ function dataUrl(file) {
200
+ const type = file.endsWith(".svg") ? "image/svg+xml" : "image/png"
201
+ return `data:${type};base64,${readFileSync(file).toString("base64")}`
202
+ }
203
+
204
+ async function playIcon(logo, { background, iconScale }, file) {
205
+ const mark = await sharp(readFileSync(logo))
206
+ .resize(Math.round(PLAY_ICON_SIZE * iconScale))
207
+ .png()
208
+ .toBuffer()
209
+ await sharp({
210
+ create: {
211
+ width: PLAY_ICON_SIZE,
212
+ height: PLAY_ICON_SIZE,
213
+ channels: 4,
214
+ background,
215
+ },
216
+ })
217
+ .composite([{ input: mark, gravity: "centre" }])
218
+ .png()
219
+ .toFile(file)
220
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { DEFAULT_DEVICES, resolveDevices, screenUrl } from "./devices.js"
4
+ import { promoHtml } from "./promo.js"
5
+
6
+ describe("resolveDevices", () => {
7
+ it("defaults to the sizes both stores require", () => {
8
+ expect(resolveDevices().map(([name]) => name)).toEqual(DEFAULT_DEVICES)
9
+ })
10
+
11
+ it("gives each device the store's pixel size", () => {
12
+ const [[, iphone]] = resolveDevices(["iphone-6.9"])
13
+ expect(iphone.viewport.width * iphone.scale).toBe(1320)
14
+ expect(iphone.viewport.height * iphone.scale).toBe(2868)
15
+ })
16
+
17
+ it("names the devices it doesn't know", () => {
18
+ expect(() => resolveDevices(["iphone-6.9", "watch"])).toThrow("watch")
19
+ })
20
+ })
21
+
22
+ describe("screenUrl", () => {
23
+ it("adds the locale to the path's own query", () => {
24
+ expect(
25
+ screenUrl("http://localhost:5173", "/jobs?status=open", {
26
+ locale: "it",
27
+ localeParam: "lang",
28
+ }),
29
+ ).toBe("http://localhost:5173/jobs?status=open&lang=it")
30
+ })
31
+
32
+ it("leaves the path alone when the app doesn't read the locale from links", () => {
33
+ expect(screenUrl("http://localhost:5173", "/jobs", { locale: "it" })).toBe(
34
+ "http://localhost:5173/jobs",
35
+ )
36
+ })
37
+ })
38
+
39
+ describe("promoHtml", () => {
40
+ const options = {
41
+ logo: "data:image/png;base64,",
42
+ name: "Tom & Jerry",
43
+ tagline: "First <line>\nSecond",
44
+ background: "#fff",
45
+ }
46
+
47
+ it("escapes the name and breaks the tagline into lines", () => {
48
+ const html = promoHtml("feature-graphic", options)
49
+ expect(html).toContain("Tom &amp; Jerry")
50
+ expect(html).toContain("First &lt;line&gt;<br>Second")
51
+ })
52
+
53
+ it("frames a screenshot at each edge of the header", () => {
54
+ const html = promoHtml("header", { ...options, shots: ["a.png", "b.png"] })
55
+ expect(html).toMatch(/src="a\.png"[^>]*left:/)
56
+ expect(html).toMatch(/src="b\.png"[^>]*right:/)
57
+ })
58
+
59
+ it("leaves out what isn't given", () => {
60
+ const html = promoHtml("search-results", options)
61
+ expect(html).not.toContain('<img src="a.png')
62
+ expect(html).not.toContain("opacity")
63
+ })
64
+ })
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The promotional images, in CSS pixels rendered at `scale`:
3
+ * - `feature-graphic`: Google Play's banner above the listing.
4
+ * - `header`: a wide banner whose middle 3:2 is what an iPhone shows, so the
5
+ * text stays there and the screenshots sit at the edges.
6
+ * - `search-results`: a 3:2 card, the text beside one screenshot.
7
+ */
8
+ export const PROMO_FORMATS = {
9
+ "feature-graphic": { width: 1024, height: 500, scale: 1 },
10
+ header: { width: 1920, height: 823, scale: 2 },
11
+ "search-results": { width: 960, height: 640, scale: 2 },
12
+ }
13
+
14
+ const DEFAULT_COLOR = "#1f1f1f"
15
+ const DEFAULT_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
16
+ const PERCENT = 100
17
+ const HALF = 2
18
+
19
+ // Sizes in hundredths of the image's height.
20
+ const FEATURE_GRAPHIC = {
21
+ gap: 11,
22
+ padding: 16,
23
+ logo: 40,
24
+ name: 19,
25
+ tagline: 7.5,
26
+ }
27
+ const HEADER = {
28
+ safeRatio: 1.5,
29
+ phoneTop: 11,
30
+ phoneSide: 2,
31
+ phoneHeight: 79,
32
+ textTop: 18,
33
+ textBottom: 8,
34
+ logo: 16,
35
+ name: 17,
36
+ taglineGap: 5,
37
+ tagline: 8.5,
38
+ }
39
+ const SEARCH_RESULTS = {
40
+ textShare: 0.55,
41
+ padding: 8,
42
+ phoneHeight: 82,
43
+ logo: 14,
44
+ name: 13,
45
+ taglineGap: 5,
46
+ tagline: 6.5,
47
+ subtitleGap: 3,
48
+ subtitle: 3.6,
49
+ subtitleOpacity: 0.75,
50
+ }
51
+ const BRAND_GAP = 3
52
+ const ACCENT = { gap: 4, width: 14, height: 1 }
53
+ const PHONE = { radius: 5, border: 0.9, shadowY: 2, shadowBlur: 6 }
54
+
55
+ const LAYOUTS = {
56
+ "feature-graphic": featureGraphic,
57
+ header,
58
+ "search-results": searchResults,
59
+ }
60
+
61
+ /**
62
+ * @param {keyof typeof PROMO_FORMATS} format
63
+ * @param {{ logo: string, name: string, tagline?: string, subtitle?: string, shots?: string[], background: string, color?: string, accent?: string, font?: string }} options
64
+ * `logo` and `shots` are image addresses.
65
+ * @returns {string} A whole HTML page.
66
+ */
67
+ export function promoHtml(format, options) {
68
+ const { width, height } = PROMO_FORMATS[format]
69
+ const style = {
70
+ color: DEFAULT_COLOR,
71
+ font: DEFAULT_FONT,
72
+ tagline: "",
73
+ subtitle: "",
74
+ shots: [],
75
+ ...options,
76
+ }
77
+ const unit = height / PERCENT
78
+ return `<!doctype html><html><body style="margin:0">
79
+ <div style="position:relative;width:${width}px;height:${height}px;box-sizing:border-box;overflow:hidden;
80
+ background:${style.background};color:${style.color};font-family:${style.font}">
81
+ ${LAYOUTS[format]({ width, height, unit, ...style })}
82
+ </div></body></html>`
83
+ }
84
+
85
+ function featureGraphic({ unit, logo, name, tagline }) {
86
+ const size = scaled(FEATURE_GRAPHIC, unit)
87
+ return `<div style="height:100%;display:flex;align-items:center;gap:${size.gap}px;padding:0 ${size.padding}px">
88
+ <img src="${logo}" style="height:${size.logo}px;flex:none">
89
+ <div>
90
+ <div style="font-size:${size.name}px;line-height:1">${escapeHtml(name)}</div>
91
+ ${text(tagline, `margin-top:${size.tagline}px;font-size:${size.tagline}px;line-height:1.25`)}
92
+ </div>
93
+ </div>`
94
+ }
95
+
96
+ function header({
97
+ width,
98
+ height,
99
+ unit,
100
+ logo,
101
+ name,
102
+ tagline,
103
+ shots,
104
+ color,
105
+ accent,
106
+ }) {
107
+ const size = scaled(HEADER, unit)
108
+ const safeWidth = Math.round(height * HEADER.safeRatio)
109
+ const safeLeft = (width - safeWidth) / HALF
110
+ const [left, right] = shots
111
+ const placement = (side) =>
112
+ `top:${size.phoneTop}px;${side}:${size.phoneSide}px;height:${size.phoneHeight}px`
113
+ return `${left ? phone(left, unit, color, placement("left")) : ""}
114
+ ${right ? phone(right, unit, color, placement("right")) : ""}
115
+ <div style="position:absolute;left:${safeLeft}px;width:${safeWidth}px;top:${size.textTop}px;bottom:${size.textBottom}px;
116
+ display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center">
117
+ ${brand(unit, logo, name, size)}
118
+ ${text(tagline, `margin-top:${size.taglineGap}px;font-size:${size.tagline}px;line-height:1.2`)}
119
+ ${accentBar(unit, accent)}
120
+ </div>`
121
+ }
122
+
123
+ function searchResults({
124
+ width,
125
+ unit,
126
+ logo,
127
+ name,
128
+ tagline,
129
+ subtitle,
130
+ shots,
131
+ color,
132
+ accent,
133
+ }) {
134
+ const size = scaled(SEARCH_RESULTS, unit)
135
+ const [shot] = shots
136
+ return `<div style="height:100%;display:flex;align-items:center;justify-content:space-between;padding:0 ${size.padding}px">
137
+ <div style="max-width:${width * SEARCH_RESULTS.textShare}px">
138
+ ${brand(unit, logo, name, size)}
139
+ ${text(tagline, `margin-top:${size.taglineGap}px;font-size:${size.tagline}px;line-height:1.2`)}
140
+ ${text(subtitle, `margin-top:${size.subtitleGap}px;font-size:${size.subtitle}px;line-height:1.4;opacity:${SEARCH_RESULTS.subtitleOpacity}`)}
141
+ ${accentBar(unit, accent)}
142
+ </div>
143
+ ${shot ? phone(shot, unit, color, `position:static;height:${size.phoneHeight}px`) : ""}
144
+ </div>`
145
+ }
146
+
147
+ function scaled(sizes, unit) {
148
+ return Object.fromEntries(
149
+ Object.entries(sizes).map(([key, value]) => [key, value * unit]),
150
+ )
151
+ }
152
+
153
+ function brand(unit, logo, name, size) {
154
+ return `<div style="display:flex;align-items:center;gap:${BRAND_GAP * unit}px">
155
+ <img src="${logo}" style="height:${size.logo}px">
156
+ <span style="font-size:${size.name}px;line-height:1">${escapeHtml(name)}</span>
157
+ </div>`
158
+ }
159
+
160
+ function text(value, style) {
161
+ return value ? `<div style="${style}">${lines(value)}</div>` : ""
162
+ }
163
+
164
+ function accentBar(unit, accent) {
165
+ if (!accent) return ""
166
+ const size = scaled(ACCENT, unit)
167
+ return `<div style="margin-top:${size.gap}px;width:${size.width}px;height:${size.height}px;border-radius:${size.height}px;background:${accent}"></div>`
168
+ }
169
+
170
+ function phone(src, unit, color, placement) {
171
+ const size = scaled(PHONE, unit)
172
+ return `<img src="${src}" style="position:absolute;${placement};border-radius:${size.radius}px;
173
+ border:${size.border}px solid ${color};box-shadow:0 ${size.shadowY}px ${size.shadowBlur}px rgba(0,0,0,.2)">`
174
+ }
175
+
176
+ function lines(value) {
177
+ return escapeHtml(value).split("\n").join("<br>")
178
+ }
179
+
180
+ function escapeHtml(value) {
181
+ return value
182
+ .replaceAll("&", "&amp;")
183
+ .replaceAll("<", "&lt;")
184
+ .replaceAll(">", "&gt;")
185
+ .replaceAll('"', "&quot;")
186
+ }
package/types/index.d.ts CHANGED
@@ -81,6 +81,82 @@ export declare function configure(options?: {
81
81
  env?: NodeJS.ProcessEnv
82
82
  }): Promise<string[]>
83
83
 
84
+ /** A device whose store screenshots `listingAssets` captures. */
85
+ export type ListingDevice =
86
+ "iphone-6.9" | "iphone-6.3" | "ipad-13" | "android" | "tablet-7" | "tablet-10"
87
+
88
+ /** The default export of `listing.config.js`. */
89
+ export interface ListingConfig {
90
+ /** The running app. Defaults to `http://localhost:5173`. */
91
+ url?: string
92
+ /** One set of screenshots each. Defaults to one set, in the browser's locale. */
93
+ locales?: string[]
94
+ /** The query parameter the app reads the locale from, such as `lang`. */
95
+ localeParam?: string
96
+ /** Defaults to `["iphone-6.9", "android"]`. */
97
+ devices?: ListingDevice[]
98
+ /** Defaults to `listing-assets`. */
99
+ out?: string
100
+ /** The Playwright browser channel. Defaults to `chrome`. */
101
+ browser?: string
102
+ /** Milliseconds to wait after a screen loads. Defaults to `1000`. */
103
+ settle?: number
104
+ /**
105
+ * `localStorage` entries set before the app starts, such as answers to the
106
+ * consent and push prompts, so they stay out of the screenshots.
107
+ */
108
+ storage?:
109
+ | Record<string, unknown>
110
+ | ((locale: string | null) => Record<string, unknown>)
111
+ /** Signs the demo account in, once per locale: the other devices reuse the session. */
112
+ signIn?: (
113
+ page: import("playwright-core").Page,
114
+ context: { url: string; locale: string | null },
115
+ ) => Promise<void>
116
+ /** In the order the stores show them. */
117
+ screens: {
118
+ /** The file name, such as `01-feed`. */
119
+ name: string
120
+ path: string
121
+ /** Brings the screen to the state worth showing, such as a filled form. */
122
+ prepare?: (
123
+ page: import("playwright-core").Page,
124
+ context: { locale: string | null },
125
+ ) => Promise<void>
126
+ }[]
127
+ /** The feature graphic, header and search results images. */
128
+ promo?: {
129
+ /** One line, or one per locale. A newline breaks the line. */
130
+ tagline?: string | Record<string, string>
131
+ /** The search results card's smaller line under the tagline. */
132
+ subtitle?: string | Record<string, string>
133
+ /**
134
+ * The screenshots framed in the header (both) and search results card
135
+ * (the first). Defaults to the first two `screens`.
136
+ */
137
+ screens?: string[]
138
+ /** Whose screenshots to frame. Defaults to the first iPhone in `devices`. */
139
+ device?: ListingDevice
140
+ /** Defaults to `native.assets.background`. */
141
+ background?: string
142
+ /** Text and phone frames. Defaults to `#1f1f1f`. */
143
+ color?: string
144
+ /** A short bar under the text, in this colour. None by default. */
145
+ accent?: string
146
+ /** A CSS font-family. Defaults to the system font. */
147
+ font?: string
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Captures store screenshots of the running app and makes the promotional
153
+ * images and Google Play's 512px icon, from `listing.config.js`.
154
+ * @returns The folder written to.
155
+ */
156
+ export declare function listingAssets(options?: {
157
+ root?: string
158
+ }): Promise<string>
159
+
84
160
  /** Makes the native icons and splash screens from one logo. */
85
161
  export declare function assets(options?: {
86
162
  root?: string