@niro53/store-tools 0.1.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/dist/render.js ADDED
@@ -0,0 +1,122 @@
1
+ import { chromium } from '@playwright/test';
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+ import { join, resolve, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const TEMPLATES_DIR = join(__dirname, '..', 'templates');
7
+ // ── Platform sizes ────────────────────────────────────────────────────────────
8
+ const APPSTORE_W = 1320;
9
+ const APPSTORE_H = 2868; // iPhone 6.9" required (iPhone 16 Pro Max)
10
+ const PLAY_H = 2640; // Google Play max 2:1 clip
11
+ const FEATURE_W = 1024;
12
+ const FEATURE_H = 500;
13
+ // ── Per-scene render ──────────────────────────────────────────────────────────
14
+ let _sharedCss;
15
+ let _helpersJs;
16
+ async function getSharedAssets() {
17
+ _sharedCss ??= await readFile(join(TEMPLATES_DIR, 'styles.css'), 'utf-8');
18
+ _helpersJs ??= await readFile(join(TEMPLATES_DIR, 'helpers.js'), 'utf-8');
19
+ return { css: _sharedCss, js: _helpersJs };
20
+ }
21
+ async function renderScene(browser, htmlPath, locale, outFile, scene, appStylesPath) {
22
+ const isFeature = scene.kind === 'feature';
23
+ const vw = isFeature ? FEATURE_W : APPSTORE_W;
24
+ const vh = isFeature ? FEATURE_H : APPSTORE_H;
25
+ const { css: sharedCss, js: helpersJs } = await getSharedAssets();
26
+ const ctx = await browser.newContext({ viewport: { width: vw, height: vh }, deviceScaleFactor: 1 });
27
+ const page = await ctx.newPage();
28
+ // Inject shared helpers before any page scripts run.
29
+ await page.addInitScript({ content: helpersJs });
30
+ const url = `file://${htmlPath}?locale=${locale}`;
31
+ await page.goto(url, { waitUntil: 'networkidle' });
32
+ // Inject shared CSS (before optional per-app overrides).
33
+ await page.addStyleTag({ content: sharedCss });
34
+ if (appStylesPath) {
35
+ try {
36
+ const appCss = await readFile(appStylesPath, 'utf-8');
37
+ await page.addStyleTag({ content: appCss });
38
+ }
39
+ catch { /* no per-app override, fine */ }
40
+ }
41
+ await page.waitForFunction(() => window['__SCENE_READY'] === true, { timeout: 15_000 });
42
+ const selector = isFeature ? '.feature' : '.screen';
43
+ const el = await page.$(selector);
44
+ if (!el)
45
+ throw new Error(`Selector "${selector}" not found in ${htmlPath}`);
46
+ await el.screenshot({ path: outFile, omitBackground: false });
47
+ await ctx.close();
48
+ }
49
+ async function renderScenePlay(browser, htmlPath, locale, outFile, appStylesPath) {
50
+ // Render at full App Store size then clip the top PLAY_H pixels.
51
+ const { css: sharedCss, js: helpersJs } = await getSharedAssets();
52
+ const ctx = await browser.newContext({ viewport: { width: APPSTORE_W, height: APPSTORE_H }, deviceScaleFactor: 1 });
53
+ const page = await ctx.newPage();
54
+ await page.addInitScript({ content: helpersJs });
55
+ const url = `file://${htmlPath}?locale=${locale}`;
56
+ await page.goto(url, { waitUntil: 'networkidle' });
57
+ await page.addStyleTag({ content: sharedCss });
58
+ if (appStylesPath) {
59
+ try {
60
+ const appCss = await readFile(appStylesPath, 'utf-8');
61
+ await page.addStyleTag({ content: appCss });
62
+ }
63
+ catch { /* no per-app override */ }
64
+ }
65
+ await page.waitForFunction(() => window['__SCENE_READY'] === true, { timeout: 15_000 });
66
+ await page.screenshot({
67
+ path: outFile,
68
+ clip: { x: 0, y: 0, width: APPSTORE_W, height: PLAY_H },
69
+ omitBackground: false,
70
+ });
71
+ await ctx.close();
72
+ }
73
+ export async function renderScreenshots({ appDir, config, locales, scenes: sceneFilter, platforms = ['appstore', 'play'], verbose = true, }) {
74
+ const sourceDir = join(appDir, 'store-assets', 'source');
75
+ const outRoot = join(appDir, 'store-assets', 'rendered');
76
+ const appStylesPath = join(sourceDir, 'styles.css'); // optional per-app overrides
77
+ const targetLocales = locales ?? Object.keys(config.locales);
78
+ const targetScenes = sceneFilter
79
+ ? config.scenes.filter((s) => sceneFilter.includes(s.out))
80
+ : config.scenes;
81
+ const log = (msg) => { if (verbose)
82
+ process.stdout.write(msg + '\n'); };
83
+ const browser = await chromium.launch({ args: ['--font-render-hinting=none'] });
84
+ try {
85
+ let count = 0;
86
+ for (const locale of targetLocales) {
87
+ // ── App Store ──────────────────────────────────────────────────────────
88
+ if (platforms.includes('appstore')) {
89
+ const localeDir = join(outRoot, locale);
90
+ await mkdir(localeDir, { recursive: true });
91
+ for (const scene of targetScenes.filter((s) => s.kind !== 'feature')) {
92
+ const htmlPath = join(sourceDir, scene.html);
93
+ const outFile = join(localeDir, `${scene.out}.png`);
94
+ await renderScene(browser, htmlPath, locale, outFile, scene, appStylesPath);
95
+ log(` ✓ ${resolve(appDir, '..', '..').split('/').at(-1) ?? ''}/${locale}/${scene.out}.png`);
96
+ count++;
97
+ }
98
+ }
99
+ // ── Google Play screens ────────────────────────────────────────────────
100
+ if (platforms.includes('play')) {
101
+ const playDir = join(outRoot, `${locale}-play`);
102
+ await mkdir(playDir, { recursive: true });
103
+ for (const scene of targetScenes) {
104
+ const htmlPath = join(sourceDir, scene.html);
105
+ const outFile = join(playDir, `${scene.out}.png`);
106
+ if (scene.kind === 'feature') {
107
+ await renderScene(browser, htmlPath, locale, outFile, scene, appStylesPath);
108
+ }
109
+ else {
110
+ await renderScenePlay(browser, htmlPath, locale, outFile, appStylesPath);
111
+ }
112
+ log(` ✓ ${locale}-play/${scene.out}.png`);
113
+ count++;
114
+ }
115
+ }
116
+ }
117
+ log(`\n✓ Rendered ${count} images → store-assets/rendered/`);
118
+ }
119
+ finally {
120
+ await browser.close();
121
+ }
122
+ }
@@ -0,0 +1,19 @@
1
+ import type { StoreConfig } from './config.js';
2
+ export interface ValidationError {
3
+ field: string;
4
+ message: string;
5
+ }
6
+ export interface ValidationResult {
7
+ ok: boolean;
8
+ errors: ValidationError[];
9
+ }
10
+ export declare function validateConfig(appDir: string, config: StoreConfig, opts?: {
11
+ checkSceneFiles?: boolean;
12
+ }): Promise<ValidationResult>;
13
+ /** Validate all apps in the monorepo — used by CI (`pnpm store validate --all`). */
14
+ export declare function validateAll(appDirs: {
15
+ appDir: string;
16
+ config: StoreConfig;
17
+ }[], opts?: {
18
+ checkSceneFiles?: boolean;
19
+ }): Promise<boolean>;
@@ -0,0 +1,109 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { StoreConfigSchema } from './config.js';
4
+ // ── Uniqueness registry (cross-app duplicate guard, populated per run) ────────
5
+ const registry = new Map(); // "field:value" → slug
6
+ function checkUnique(slug, field, value, errors) {
7
+ const key = `${field}:${value.toLowerCase().trim()}`;
8
+ const existing = registry.get(key);
9
+ if (existing && existing !== slug) {
10
+ errors.push({ field, message: `Duplicate ${field} across apps "${slug}" and "${existing}" (Apple 4.3 shell-app risk)` });
11
+ }
12
+ else {
13
+ registry.set(key, slug);
14
+ }
15
+ }
16
+ // ── Per-app validation ────────────────────────────────────────────────────────
17
+ export async function validateConfig(appDir, config, opts = {}) {
18
+ const errors = [];
19
+ // 1. Zod parse (re-validates; surfaces any schema errors cleanly)
20
+ const parsed = StoreConfigSchema.safeParse(config);
21
+ if (!parsed.success) {
22
+ for (const issue of parsed.error.issues) {
23
+ errors.push({ field: issue.path.join('.'), message: issue.message });
24
+ }
25
+ return { ok: false, errors };
26
+ }
27
+ // 2. Per-locale completeness (shortDescription recommended for Play)
28
+ for (const [locale, meta] of Object.entries(config.locales)) {
29
+ if (!meta.shortDescription) {
30
+ errors.push({
31
+ field: `locales.${locale}.shortDescription`,
32
+ message: 'Recommended for Google Play; missing will use the first paragraph of description',
33
+ });
34
+ }
35
+ if (!meta.subtitle) {
36
+ errors.push({
37
+ field: `locales.${locale}.subtitle`,
38
+ message: 'Recommended for App Store; missing = no subtitle shown in search results',
39
+ });
40
+ }
41
+ if (!meta.releaseNotes) {
42
+ errors.push({
43
+ field: `locales.${locale}.releaseNotes`,
44
+ message: 'Missing release notes — upload will use last submitted notes',
45
+ });
46
+ }
47
+ }
48
+ // 3. Cross-app duplicate detection (Apple guideline 4.3)
49
+ for (const [locale, meta] of Object.entries(config.locales)) {
50
+ checkUnique(config.slug, `locales.${locale}.name`, meta.name, errors);
51
+ if (meta.subtitle)
52
+ checkUnique(config.slug, `locales.${locale}.subtitle`, meta.subtitle, errors);
53
+ }
54
+ // 4. Scene HTML file existence
55
+ if (opts.checkSceneFiles) {
56
+ const sourceDir = join(appDir, 'store-assets', 'source');
57
+ await Promise.all(config.scenes.map(async (scene) => {
58
+ try {
59
+ await access(join(sourceDir, scene.html));
60
+ }
61
+ catch {
62
+ errors.push({
63
+ field: `scenes[${scene.out}].html`,
64
+ message: `Scene file not found: ${join(sourceDir, scene.html)}`,
65
+ });
66
+ }
67
+ }));
68
+ }
69
+ // 5. Must have at least one screen scene
70
+ const hasScreen = config.scenes.some((s) => s.kind === 'screen');
71
+ if (!hasScreen) {
72
+ errors.push({ field: 'scenes', message: 'At least one scene with kind="screen" is required' });
73
+ }
74
+ // 6. Apple: App Store requires appleId + itcTeamId for release uploads
75
+ if (!config.appleId) {
76
+ errors.push({ field: 'appleId', message: 'appleId is required for App Store uploads' });
77
+ }
78
+ if (!config.itcTeamId) {
79
+ errors.push({ field: 'itcTeamId', message: 'itcTeamId is required for App Store uploads' });
80
+ }
81
+ // Treat missing optional fields (2–3 above) as warnings, not hard failures
82
+ const hardErrors = errors.filter((e) => !e.field.includes('shortDescription') &&
83
+ !e.field.includes('subtitle') &&
84
+ !e.field.includes('releaseNotes') &&
85
+ !e.field.includes('appleId') &&
86
+ !e.field.includes('itcTeamId'));
87
+ return { ok: hardErrors.length === 0, errors };
88
+ }
89
+ /** Validate all apps in the monorepo — used by CI (`pnpm store validate --all`). */
90
+ export async function validateAll(appDirs, opts = {}) {
91
+ registry.clear(); // reset cross-app uniqueness per run
92
+ let allOk = true;
93
+ for (const { appDir, config } of appDirs) {
94
+ const result = await validateConfig(appDir, config, opts);
95
+ if (result.errors.length > 0) {
96
+ console.log(`\n${config.slug}:`);
97
+ for (const err of result.errors) {
98
+ const prefix = result.ok ? ' ⚠' : ' ✗';
99
+ console.log(`${prefix} ${err.field}: ${err.message}`);
100
+ }
101
+ }
102
+ else {
103
+ console.log(` ✓ ${config.slug} — OK`);
104
+ }
105
+ if (!result.ok)
106
+ allOk = false;
107
+ }
108
+ return allOk;
109
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@niro53/store-tools",
3
+ "version": "0.1.0",
4
+ "description": "Apple App Store + Google Play screenshot rendering, metadata sync, and fastlane upload pipeline for Capacitor apps.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "niro-store": "bin/cli.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "templates",
20
+ "bin"
21
+ ],
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run --passWithNoTests",
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "prepublishOnly": "pnpm build"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "commander": "^12.1.0",
33
+ "zod": "^3.24.4"
34
+ },
35
+ "devDependencies": {
36
+ "@niro/tsconfig": "workspace:*",
37
+ "@playwright/test": "^1.52.0",
38
+ "@types/node": "^22.0.0",
39
+ "typescript": "^5.8.3",
40
+ "vitest": "^3.2.1"
41
+ },
42
+ "peerDependencies": {
43
+ "@playwright/test": ">=1.40.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@playwright/test": {
47
+ "optional": false
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,105 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * @niro/store-tools — shared scene helpers
4
+ * Injected by render.ts via Playwright addInitScript BEFORE the page scripts.
5
+ *
6
+ * Exposes: window.STORE (utility namespace)
7
+ * STORE.locale — resolved locale string ("en", "cs", "pl", …)
8
+ * STORE.multiline(s) — replace \n in translation strings with <br>
9
+ * STORE.statusBarHTML() — iOS-style "9:41" status bar HTML
10
+ * STORE.bottomNavHTML(items, activeIndex) — bottom-nav HTML
11
+ * STORE.serviceLogo(name, size, opts) — service logo tile HTML (optional)
12
+ * STORE.brandLogoSVG(svgContent) — wrapped brand logo
13
+ *
14
+ * Service logos / brand colors are optional — only apps that show service
15
+ * tiles need to populate window.SERVICE_BG / window.SERVICE_LOGO_SVG.
16
+ */
17
+ (function () {
18
+ /* ─── Locale detection ─────────────────────────────────────────────── */
19
+ const params = new URLSearchParams(window.location.search);
20
+ const raw = (params.get('locale') || 'en').toLowerCase();
21
+ window.LOCALE = raw;
22
+
23
+ /* ─── STORE namespace ──────────────────────────────────────────────── */
24
+ window.STORE = {
25
+ get locale() { return window.LOCALE; },
26
+
27
+ /** Replace literal \n (or \\n) with <br> — for multi-line headline strings. */
28
+ multiline(str) {
29
+ if (!str) return '';
30
+ return String(str).replace(/\\n|\n/g, '<br>');
31
+ },
32
+
33
+ /** iOS-style "9:41 ▪ wifi ▪ battery" status bar. */
34
+ statusBarHTML() {
35
+ return `
36
+ <div class="statusbar">
37
+ <span>9:41</span>
38
+ <div class="icons">
39
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
40
+ <path d="M1.333 7.46a13.81 13.81 0 0 1 21.334 0l-1.77 1.77a11.31 11.31 0 0 0-17.794 0L1.333 7.46z"/>
41
+ <path d="M5.333 11.46a8.318 8.318 0 0 1 13.334 0l-1.77 1.77a5.818 5.818 0 0 0-9.794 0l-1.77-1.77z"/>
42
+ <path d="M12 17a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
43
+ </svg>
44
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
45
+ <rect x="1" y="7" width="18" height="10" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/>
46
+ <rect x="19.5" y="10" width="2.5" height="4" rx="1" fill="currentColor"/>
47
+ <rect x="2.5" y="8.5" width="14" height="7" rx="1" fill="currentColor"/>
48
+ </svg>
49
+ </div>
50
+ </div>`;
51
+ },
52
+
53
+ /**
54
+ * Generic bottom nav.
55
+ * @param items Array of { label, icon (SVG string) }
56
+ * @param active Zero-based active index, or -1 to put FAB (+ button) in the centre.
57
+ */
58
+ bottomNavHTML(items, active) {
59
+ if (!items || items.length === 0) return '';
60
+ return `
61
+ <div class="bottom-nav">
62
+ ${items.map((item, i) => `
63
+ <div class="item ${i === active ? 'active' : ''}">
64
+ ${item.icon || ''}
65
+ <span>${item.label || ''}</span>
66
+ </div>
67
+ `).join('')}
68
+ </div>`;
69
+ },
70
+
71
+ /**
72
+ * Service logo tile HTML. Requires window.SERVICE_BG + window.SERVICE_LOGO_SVG.
73
+ * @param name Service name (must be a key in SERVICE_BG / SERVICE_LOGO_SVG)
74
+ * @param size Tile size in px (default 84)
75
+ * @param opts { radius: borderRadius in px }
76
+ */
77
+ serviceLogo(name, size = 84, opts = {}) {
78
+ const bg = (window.SERVICE_BG || {})[name] || '#333';
79
+ const svg = (window.SERVICE_LOGO_SVG || {})[name] || '';
80
+ const r = opts.radius ?? 14;
81
+ const s = Number(size);
82
+ const iconSize = Math.round(s * 0.55);
83
+ return `
84
+ <div class="logo" style="
85
+ background:${bg};
86
+ width:${s}px;height:${s}px;
87
+ border-radius:${r}px;
88
+ display:flex;align-items:center;justify-content:center;
89
+ flex-shrink:0;">
90
+ ${svg.replace(/viewBox="0 0 24 24">/, `viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}">`)}
91
+ </div>`;
92
+ },
93
+
94
+ /**
95
+ * Wrap an SVG string in a standardised brand-logo container.
96
+ * @param svgContent Raw <svg …>…</svg> string
97
+ */
98
+ brandLogoSVG(svgContent) {
99
+ return svgContent || '';
100
+ },
101
+ };
102
+
103
+ /* ─── Scene readiness ──────────────────────────────────────────────── */
104
+ window.__SCENE_READY = false;
105
+ })();