create-techtiten-app 0.0.0-stage → 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 +88 -2
- package/bin/index.mjs +345 -0
- package/bin/presets.mjs +68 -0
- package/package.json +38 -4
- package/templates/mobile/ads/src/ads/Banner.tsx +14 -0
- package/templates/mobile/ads/src/ads/Banner.web.tsx +4 -0
- package/templates/mobile/common/app.config.ts +36 -0
- package/templates/mobile/common/config/plugins.json +1 -0
- package/templates/mobile/common/distribution/whatsnew/whatsnew-en-US +1 -0
- package/templates/mobile/common/plugins/withSigning.js +63 -0
- package/templates/mobile/common/src/AppProviders.tsx +22 -0
- package/templates/mobile/common/src/api/client.ts +16 -0
- package/templates/mobile/common/src/screens/Home.tsx +48 -0
- package/templates/mobile/common/src/screens/Settings.tsx +22 -0
- package/templates/mobile/common/src/theme/index.tsx +17 -0
- package/templates/mobile/common/src/ui/index.tsx +41 -0
- package/templates/mobile/common/tsconfig.json +8 -0
- package/templates/mobile/nav-stack/app/_layout.tsx +33 -0
- package/templates/mobile/nav-stack/app/index.tsx +1 -0
- package/templates/mobile/nav-stack/app/settings.tsx +1 -0
- package/templates/mobile/nav-tabs/app/(tabs)/_layout.tsx +25 -0
- package/templates/mobile/nav-tabs/app/(tabs)/index.tsx +1 -0
- package/templates/mobile/nav-tabs/app/(tabs)/settings.tsx +1 -0
- package/templates/mobile/nav-tabs/app/_layout.tsx +25 -0
- package/templates/mobile/noads/src/ads/Banner.tsx +4 -0
- package/templates/mobile/redux/src/state/Providers.tsx +13 -0
- package/templates/mobile/redux/src/state/index.ts +16 -0
- package/templates/mobile/redux/src/state/store.ts +33 -0
- package/templates/mobile/zustand/src/state/Providers.tsx +7 -0
- package/templates/mobile/zustand/src/state/index.ts +25 -0
- package/templates/packages/shared/package.json +7 -0
- package/templates/packages/shared/src/format.ts +18 -0
- package/templates/packages/shared/src/index.ts +5 -0
- package/templates/packages/shared/src/money.ts +13 -0
- package/templates/packages/shared/src/schemas.ts +9 -0
- package/templates/packages/theme/package.json +7 -0
- package/templates/packages/theme/src/index.ts +26 -0
- package/templates/root/.prettierignore +7 -0
- package/templates/root/.prettierrc +5 -0
- package/templates/root/AGENTS.md +16 -0
- package/templates/root/README.md +31 -0
- package/templates/root/STATUS.md +13 -0
- package/templates/root/_github/workflows/android.yml +79 -0
- package/templates/root/_github/workflows/web-check.yml +19 -0
- package/templates/root/_gitignore +33 -0
- package/templates/root/package.json +21 -0
- package/templates/web/src/app/(site)/page.tsx +18 -0
- package/templates/web/src/app/admin/layout.tsx +17 -0
- package/templates/web/src/app/admin/page.tsx +32 -0
- package/templates/web/src/app/api/cron/[job]/route.ts +17 -0
- package/templates/web/src/app/api/health/route.ts +8 -0
- package/templates/web/src/app/globals.css +23 -0
- package/templates/web/src/app/layout.tsx +24 -0
- package/templates/web/src/proxy.ts +26 -0
- package/templates/web/src/server/jobs.ts +9 -0
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-techtiten-app
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One command for a new app, with the same structure every time:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx create-techtiten-app my-app
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
my-app/
|
|
13
|
+
├─ apps/mobile Expo (latest SDK), Expo Router, TypeScript
|
|
14
|
+
├─ apps/web Next.js (latest): website + /admin panel + /api backend (optional)
|
|
15
|
+
├─ packages/theme colors, spacing, radius, font sizes, shared by both apps
|
|
16
|
+
├─ packages/shared functions, types, zod schemas, shared by both apps
|
|
17
|
+
├─ .github/workflows Android build → Google Play (only when mobile changes) + web check
|
|
18
|
+
└─ AGENTS.md, STATUS.md, .gitignore (secrets blocked)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Options
|
|
22
|
+
Run `npx create-techtiten-app --help` any time to see them all. It asks these questions, or you pass them as flags:
|
|
23
|
+
|
|
24
|
+
| Question | Flag | Choices |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| Display name | `--name="My App"` | anything |
|
|
27
|
+
| Package id | `--package=com.techtiten.myapp` | permanent once on Play |
|
|
28
|
+
| Web app | `--web` / `--no-web` | Next.js: website + `/admin` + `/api` |
|
|
29
|
+
| Navigation | `--nav=stack` / `--nav=tabs` | stack, bottom tabs |
|
|
30
|
+
| State | `--state=zustand` / `--state=redux` | both with TanStack Query |
|
|
31
|
+
| Color theme | `--theme=<preset>` | `techtiten`, `ocean`, `sunset`, `royal`, `mono`, `retro` (green LCD) |
|
|
32
|
+
| Brand color | `--brand=#FF3366` | optional, replaces the preset's brand color |
|
|
33
|
+
| Font | `--font=<font>` | `system`, `inter`, `poppins`, `space-grotesk`, `pixel` (Press Start 2P) |
|
|
34
|
+
| Ads | `--ads` / `--no-ads` | AdMob banner, test ads in development |
|
|
35
|
+
|
|
36
|
+
Other flags: `--yes` (no questions), `--skip-install`, `--no-git`, `--help`, `--version`.
|
|
37
|
+
|
|
38
|
+
### Defaults (what you get if you just press Enter, or pass `--yes`)
|
|
39
|
+
| Option | Default |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Display name | folder name in Title Case (`my-app` → `My App`) |
|
|
42
|
+
| Package id | `com.techtiten.` + folder name without dashes (`com.techtiten.myapp`) |
|
|
43
|
+
| Web app | yes |
|
|
44
|
+
| Navigation | stack |
|
|
45
|
+
| State | Zustand + TanStack Query |
|
|
46
|
+
| Theme | `techtiten` (light + dark, follows the phone's setting) |
|
|
47
|
+
| Brand color | from the theme |
|
|
48
|
+
| Font | system |
|
|
49
|
+
| Ads | no |
|
|
50
|
+
|
|
51
|
+
### Color themes
|
|
52
|
+

|
|
53
|
+
|
|
54
|
+
Your colors end up in `packages/theme/src/colors.ts`: one file, used by the phone app and the website. Change any value there later.
|
|
55
|
+
|
|
56
|
+
### Fonts
|
|
57
|
+

|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
npx create-techtiten-app retro-games --yes --no-web --ads --theme=retro --font=pixel --nav=tabs
|
|
62
|
+
npx create-techtiten-app shop --yes --web --theme=sunset --brand=#FF3366 --font=poppins --state=redux
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Every generated project also has `npm run format` (Prettier), `npm run upgrade:mobile` (latest Expo SDK) and `npm run upgrade:web` (latest Next.js).
|
|
66
|
+
|
|
67
|
+
## Add an option to this CLI
|
|
68
|
+
All options live in one list (`OPTIONS` in `bin/index.mjs`). Theme presets and fonts are in `bin/presets.mjs`. Add an entry and it appears in the questions, the flags and `--help`.
|
|
69
|
+
|
|
70
|
+
## Always the latest
|
|
71
|
+
- The base apps come from `create-expo-app@latest` and `create-next-app@latest`, so a new project starts on the newest Expo SDK and Next.js.
|
|
72
|
+
- Native packages are added with `npx expo install`, which picks the versions that match that SDK. Nothing is pinned in this package.
|
|
73
|
+
- No `android/` or `ios/` folders are stored: they are generated at build time (`expo prebuild`), so the native side always matches the SDK.
|
|
74
|
+
- The Expo template's own `app.json` values (icons, splash, defaults) are kept in `apps/mobile/config/base.json`; your settings go on top in `app.config.ts`.
|
|
75
|
+
- One React version for the whole repo (web is pinned to the version Expo installed), so hooks never break.
|
|
76
|
+
|
|
77
|
+
## What you get
|
|
78
|
+
**Mobile:** Reanimated 4 + Worklets + Gesture Handler (SDK-matched, root view already wrapped), theme provider (system/light/dark, saved), UI primitives (Screen, Card, Text, Button with haptics), state demo, API client + TanStack Query hook, release signing plugin (CI secrets or `credentials/signing.properties`), optional AdMob banner.
|
|
79
|
+
|
|
80
|
+
**Web:** landing page using the same theme colors, `/admin` dashboard behind `ADMIN_USER`/`ADMIN_PASSWORD` (`src/proxy.ts`), `GET /api/health` (shared zod schema), `POST /api/cron/[job]` protected by `CRON_API_KEY` for outside schedulers (Vercel functions are short-lived).
|
|
81
|
+
|
|
82
|
+
**CI:** `android.yml` builds a signed AAB and uploads to Play (internal track by default). Secrets: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEY_ALIAS`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_PASSWORD`, `ANDROID_SERVICE_ACCOUNT_JSON`.
|
|
83
|
+
|
|
84
|
+
## Develop this package
|
|
85
|
+
```
|
|
86
|
+
npm install
|
|
87
|
+
node bin/index.mjs test-app --yes # try it
|
|
88
|
+
npm publish # first time: npm login
|
|
89
|
+
```
|
package/bin/index.mjs
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-techtiten-app: scaffolds an npm-workspaces monorepo
|
|
3
|
+
// apps/mobile Expo (latest SDK, Expo Router, typed) always
|
|
4
|
+
// apps/web Next.js (latest): website + /admin + /api optional
|
|
5
|
+
// packages/theme colors (preset), spacing, radius, fonts shared
|
|
6
|
+
// packages/shared functions, types, zod schemas shared
|
|
7
|
+
// Latest versions every time: the base apps come from create-expo-app / create-next-app,
|
|
8
|
+
// and native-aware packages are added with `npx expo install` (SDK-matched). Nothing native
|
|
9
|
+
// is committed: android/ and ios/ are generated at build time (CNG).
|
|
10
|
+
//
|
|
11
|
+
// Every option lives in OPTIONS below: the prompts, the flags and --help are all built from it,
|
|
12
|
+
// so a new feature only has to be added once.
|
|
13
|
+
import * as p from '@clack/prompts';
|
|
14
|
+
import pc from 'picocolors';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { FONTS, THEMES, buildColors, isHex } from './presets.mjs';
|
|
20
|
+
|
|
21
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const TPL = join(ROOT, 'templates');
|
|
23
|
+
const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
|
24
|
+
|
|
25
|
+
const slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
26
|
+
const titleCase = (s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
27
|
+
const choices = (obj) => Object.entries(obj).map(([value, o]) => ({ value, label: o.label, hint: o.hint }));
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------- options (single source of truth)
|
|
30
|
+
const OPTIONS = [
|
|
31
|
+
{ key: 'name', kind: 'text', flag: '--name=<text>', help: 'App display name (under the icon)', def: (a) => titleCase(a.slug), message: 'App display name' },
|
|
32
|
+
{
|
|
33
|
+
key: 'package', kind: 'text', flag: '--package=<id>', help: 'Android package / iOS bundle id (permanent once on Play)',
|
|
34
|
+
def: (a) => `com.techtiten.${a.slug.replace(/-/g, '')}`, message: 'Android package / iOS bundle id',
|
|
35
|
+
validate: (v) => (/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(v ?? '') ? undefined : 'Like com.techtiten.myapp (lowercase, dots)'),
|
|
36
|
+
},
|
|
37
|
+
{ key: 'web', kind: 'bool', flag: '--web / --no-web', help: 'Next.js app next to the phone app: website + /admin panel + /api backend', def: () => true, message: 'Add a Next.js app (website + admin panel + API)?' },
|
|
38
|
+
{
|
|
39
|
+
key: 'nav', kind: 'select', flag: '--nav=<stack|tabs>', help: 'Navigation layout', def: () => 'stack', message: 'Navigation',
|
|
40
|
+
options: [{ value: 'stack', label: 'Stack', hint: 'screens push on top of each other' }, { value: 'tabs', label: 'Bottom tabs', hint: 'Home + Settings tabs, add more in app/(tabs)' }],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: 'state', kind: 'select', flag: '--state=<zustand|redux>', help: 'State management (both come with TanStack Query for API data)', def: () => 'zustand', message: 'State management',
|
|
44
|
+
options: [{ value: 'zustand', label: 'Zustand + TanStack Query', hint: 'recommended: tiny stores' }, { value: 'redux', label: 'Redux Toolkit + TanStack Query', hint: 'more structure, Redux DevTools' }],
|
|
45
|
+
},
|
|
46
|
+
{ key: 'theme', kind: 'select', flag: '--theme=<preset>', help: 'Color theme preset (light + dark)', def: () => 'techtiten', message: 'Color theme', options: choices(THEMES) },
|
|
47
|
+
{
|
|
48
|
+
key: 'brand', kind: 'text', flag: '--brand=<#RRGGBB>', help: 'Optional: your own brand color instead of the preset\'s', def: () => '', message: 'Custom brand color? (#RRGGBB, empty = use the preset)',
|
|
49
|
+
validate: (v) => (!v || isHex(v) ? undefined : 'Like #00C389, or leave empty'),
|
|
50
|
+
},
|
|
51
|
+
{ key: 'font', kind: 'select', flag: '--font=<font>', help: 'App font (phone app and website)', def: () => 'system', message: 'Font', options: choices(FONTS) },
|
|
52
|
+
{ key: 'ads', kind: 'bool', flag: '--ads / --no-ads', help: 'Google AdMob banner (test ads in development)', def: () => false, message: 'Google AdMob ads?' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const TOOL_FLAGS = [
|
|
56
|
+
['--yes, -y', 'Skip the questions, use defaults (or the flags you pass)'],
|
|
57
|
+
['--skip-install', 'Only write files, don\'t run npm install'],
|
|
58
|
+
['--no-git', 'Don\'t run git init / first commit'],
|
|
59
|
+
['--help, -h', 'Show this help'],
|
|
60
|
+
['--version, -v', 'Show the version'],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function printHelp() {
|
|
64
|
+
const line = (a, b) => ` ${pc.cyan(a.padEnd(28))} ${b}`;
|
|
65
|
+
console.log(`
|
|
66
|
+
${pc.bold('create-techtiten-app')} v${VERSION}
|
|
67
|
+
Scaffold an Expo app (+ optional Next.js web/admin/API) with a shared theme and shared logic.
|
|
68
|
+
Always uses the latest Expo SDK and Next.js.
|
|
69
|
+
|
|
70
|
+
${pc.bold('Usage')}
|
|
71
|
+
npx create-techtiten-app ${pc.dim('<folder>')} ${pc.dim('[options]')}
|
|
72
|
+
|
|
73
|
+
${pc.bold('Options')}
|
|
74
|
+
${OPTIONS.map((o) => line(o.flag, `${o.help}${o.options ? '' : ''}`)).join('\n')}
|
|
75
|
+
|
|
76
|
+
${pc.bold('Choices')}
|
|
77
|
+
${line('themes', Object.entries(THEMES).map(([k, t]) => `${k} (${t.label})`).join(', '))}
|
|
78
|
+
${line('fonts', Object.keys(FONTS).join(', '))}
|
|
79
|
+
|
|
80
|
+
${pc.bold('Other')}
|
|
81
|
+
${TOOL_FLAGS.map(([a, b]) => line(a, b)).join('\n')}
|
|
82
|
+
|
|
83
|
+
${pc.bold('Examples')}
|
|
84
|
+
npx create-techtiten-app my-app
|
|
85
|
+
npx create-techtiten-app retro-games --yes --no-web --ads --theme=retro --font=pixel --nav=tabs
|
|
86
|
+
npx create-techtiten-app shop --yes --web --theme=sunset --brand=#FF3366 --font=poppins --state=redux
|
|
87
|
+
`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------- args
|
|
91
|
+
const argv = process.argv.slice(2);
|
|
92
|
+
const has = (...names) => names.some((n) => argv.includes(n));
|
|
93
|
+
const val = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
|
|
94
|
+
const positional = argv.find((a) => !a.startsWith('-'));
|
|
95
|
+
const YES = has('--yes', '-y');
|
|
96
|
+
|
|
97
|
+
function fromFlags(o) {
|
|
98
|
+
if (o.kind === 'bool') return has(`--${o.key}`) ? true : has(`--no-${o.key}`) ? false : undefined;
|
|
99
|
+
return val(o.key);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkFlag(o, v) {
|
|
103
|
+
if (v === undefined) return;
|
|
104
|
+
if (o.kind === 'select' && !o.options.some((x) => x.value === v)) fail(`--${o.key}=${v} isn't valid. Choose: ${o.options.map((x) => x.value).join(', ')}`);
|
|
105
|
+
const err = o.validate?.(v);
|
|
106
|
+
if (err) fail(`--${o.key}: ${err}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function fail(msg) { p.cancel(msg); process.exit(1); }
|
|
110
|
+
const cancel = (v) => { if (p.isCancel(v)) fail('Cancelled.'); return v; };
|
|
111
|
+
|
|
112
|
+
async function ask(o, answers) {
|
|
113
|
+
const given = fromFlags(o);
|
|
114
|
+
checkFlag(o, given);
|
|
115
|
+
if (given !== undefined) return given;
|
|
116
|
+
const def = o.def(answers);
|
|
117
|
+
if (YES) return def;
|
|
118
|
+
if (o.kind === 'bool') return cancel(await p.confirm({ message: o.message, initialValue: def }));
|
|
119
|
+
if (o.kind === 'select') return cancel(await p.select({ message: o.message, options: o.options, initialValue: def }));
|
|
120
|
+
return cancel(await p.text({ message: o.message, initialValue: def, placeholder: def || undefined, validate: o.validate }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------- helpers
|
|
124
|
+
function run(cmd, args, cwd, { quiet = false, env = {} } = {}) {
|
|
125
|
+
const r = spawnSync(cmd, args, { cwd, stdio: quiet ? 'pipe' : 'inherit', shell: process.platform === 'win32', env: { ...process.env, CI: '1', EXPO_NO_TELEMETRY: '1', NEXT_TELEMETRY_DISABLED: '1', ...env } });
|
|
126
|
+
if (r.status !== 0) {
|
|
127
|
+
if (quiet) process.stderr.write(String(r.stderr ?? '') + String(r.stdout ?? ''));
|
|
128
|
+
throw new Error(`Command failed: ${cmd} ${args.join(' ')} (in ${cwd})`);
|
|
129
|
+
}
|
|
130
|
+
return String(r.stdout ?? '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Copy a template folder, replacing __TOKENS__ in text files. "_gitignore" -> ".gitignore",
|
|
134
|
+
// "_github" -> ".github" (npm drops real dot-files like .gitignore from published packages).
|
|
135
|
+
function copyTemplate(from, to, vars) {
|
|
136
|
+
if (!existsSync(from)) return;
|
|
137
|
+
for (const name of readdirSync(from)) {
|
|
138
|
+
const src = join(from, name);
|
|
139
|
+
let outName = name.replace(/\.tpl$/, '');
|
|
140
|
+
if (outName === '_gitignore') outName = '.gitignore';
|
|
141
|
+
if (outName === '_github') outName = '.github';
|
|
142
|
+
const dest = join(to, outName);
|
|
143
|
+
if (statSync(src).isDirectory()) { mkdirSync(dest, { recursive: true }); copyTemplate(src, dest, vars); continue; }
|
|
144
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
145
|
+
const buf = readFileSync(src);
|
|
146
|
+
const isText = !/\.(png|jpg|jpeg|gif|ttf|otf|woff2?|ico)$/i.test(name);
|
|
147
|
+
writeFileSync(dest, isText ? buf.toString('utf8').replace(/__([A-Z_]+)__/g, (m, k) => (k in vars ? vars[k] : m)) : buf);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const readJson = (f) => JSON.parse(readFileSync(f, 'utf8'));
|
|
152
|
+
const writeJson = (f, o) => writeFileSync(f, JSON.stringify(o, null, 2) + '\n');
|
|
153
|
+
|
|
154
|
+
// Generated files for the theme preset and the font (phone + web).
|
|
155
|
+
function writeThemeAndFonts(target, a) {
|
|
156
|
+
const colors = buildColors(a.theme, a.brand);
|
|
157
|
+
writeFileSync(join(target, 'packages/theme/src/colors.ts'),
|
|
158
|
+
`// ${THEMES[a.theme].label} preset${a.brand ? `, brand ${a.brand}` : ''} (create-techtiten-app). Edit freely.\nexport const colors = ${JSON.stringify(colors, null, 2)} as const;\n`);
|
|
159
|
+
|
|
160
|
+
const f = FONTS[a.font];
|
|
161
|
+
const m = join(target, 'apps/mobile/src/theme/fonts.ts');
|
|
162
|
+
if (!f.pkg) {
|
|
163
|
+
writeFileSync(m, `// System font (San Francisco / Roboto): nothing to load.\nexport const fonts: Record<'regular' | 'medium' | 'bold' | 'black', string | undefined> = { regular: undefined, medium: undefined, bold: undefined, black: undefined };\nexport const useAppFonts = () => true;\n/** Multiplies every font size (wide fonts like pixel ones need smaller text). */\nexport const fontScale = 1;\n`);
|
|
164
|
+
} else {
|
|
165
|
+
const names = [...new Set(Object.values(f.weights))];
|
|
166
|
+
writeFileSync(m, `// ${f.label} from ${f.pkg}. Text/Button pick the family per weight.\nimport { useFonts, ${names.join(', ')} } from '${f.pkg}';\n\nexport const fonts: Record<'regular' | 'medium' | 'bold' | 'black', string | undefined> = ${JSON.stringify(f.weights)};\n\n/** true once the font files are loaded (the root layout waits for it). */\nexport function useAppFonts() {\n const [loaded, error] = useFonts({ ${names.join(', ')} });\n return loaded || !!error;\n}\n\n/** Multiplies every font size (wide fonts like pixel ones need smaller text). */\nexport const fontScale = ${f.scale ?? 1};\n`);
|
|
167
|
+
}
|
|
168
|
+
if (a.web) {
|
|
169
|
+
const w = join(target, 'apps/web/src/app/fonts.ts');
|
|
170
|
+
if (!f.next) writeFileSync(w, `// System font.\nexport const appFontClass = '';\n`);
|
|
171
|
+
else {
|
|
172
|
+
const opts = { subsets: ['latin'], variable: '--font-app', display: 'swap', ...(f.nextWeights ? { weight: f.nextWeights } : {}) };
|
|
173
|
+
// next/font reads these options at build time and needs a plain object literal (unquoted keys).
|
|
174
|
+
const literal = '{ ' + Object.entries(opts).map(([k, v]) => `${k}: ${JSON.stringify(v).replace(/"/g, "'")}`).join(', ') + ' }';
|
|
175
|
+
writeFileSync(w, `// ${f.label} via next/font (self-hosted at build time, no layout shift).\nimport { ${f.next} } from 'next/font/google';\n\nconst font = ${f.next}(${literal});\nexport const appFontClass = font.variable;\n`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------- main
|
|
181
|
+
async function main() {
|
|
182
|
+
if (has('--help', '-h')) return printHelp();
|
|
183
|
+
if (has('--version', '-v')) return console.log(VERSION);
|
|
184
|
+
|
|
185
|
+
console.log();
|
|
186
|
+
p.intro(pc.bgGreen(pc.black(` create-techtiten-app v${VERSION} `)));
|
|
187
|
+
|
|
188
|
+
const dirName = positional ?? (YES ? 'my-app' : cancel(await p.text({ message: 'Project folder name', placeholder: 'my-app', validate: (v) => (slugify(v || '') ? undefined : 'Required') })));
|
|
189
|
+
const slug = slugify(dirName);
|
|
190
|
+
const target = resolve(process.cwd(), dirName);
|
|
191
|
+
if (existsSync(target) && readdirSync(target).length) fail(`${dirName} already exists and isn't empty.`);
|
|
192
|
+
|
|
193
|
+
const a = { slug };
|
|
194
|
+
for (const o of OPTIONS) a[o.key] = await ask(o, a);
|
|
195
|
+
if (a.brand && !a.brand.startsWith('#')) a.brand = `#${a.brand}`;
|
|
196
|
+
|
|
197
|
+
if (!YES) {
|
|
198
|
+
p.note([
|
|
199
|
+
`Name ${a.name} (${a.package})`,
|
|
200
|
+
`Apps mobile${a.web ? ' + web (site, admin, API)' : ''}`,
|
|
201
|
+
`Navigation ${a.nav} State ${a.state}`,
|
|
202
|
+
`Theme ${THEMES[a.theme].label}${a.brand ? ` with brand ${a.brand}` : ''} Font ${FONTS[a.font].label}`,
|
|
203
|
+
`Ads ${a.ads ? 'AdMob' : 'none'}`,
|
|
204
|
+
].join('\n'), 'Summary');
|
|
205
|
+
if (!cancel(await p.confirm({ message: 'Create it?', initialValue: true }))) fail('Cancelled.');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const install = !has('--skip-install');
|
|
209
|
+
const { name: displayName, package: packageId, web, state, ads } = a;
|
|
210
|
+
const scope = `@${slug}`;
|
|
211
|
+
const vars = { APP_SLUG: slug, APP_NAME: displayName, PACKAGE_ID: packageId, SCOPE: scope, STATE: state, YEAR: String(new Date().getFullYear()), WEB_ENABLED: web ? 'true' : 'false', THEME_LABEL: THEMES[a.theme].label };
|
|
212
|
+
const s = p.spinner();
|
|
213
|
+
|
|
214
|
+
// 1. Root + shared packages
|
|
215
|
+
mkdirSync(target, { recursive: true });
|
|
216
|
+
copyTemplate(join(TPL, 'root'), target, vars);
|
|
217
|
+
copyTemplate(join(TPL, 'packages'), join(target, 'packages'), vars);
|
|
218
|
+
if (!web) rmSync(join(target, '.github/workflows/web-check.yml'), { force: true });
|
|
219
|
+
const rootPkg = readJson(join(target, 'package.json'));
|
|
220
|
+
if (!web) { delete rootPkg.scripts.web; delete rootPkg.scripts['build:web']; delete rootPkg.scripts['upgrade:web']; }
|
|
221
|
+
writeJson(join(target, 'package.json'), rootPkg);
|
|
222
|
+
|
|
223
|
+
// 2. Mobile: latest Expo blank TypeScript app, then our layer on top
|
|
224
|
+
s.start('Creating the Expo app (latest SDK)');
|
|
225
|
+
mkdirSync(join(target, 'apps'), { recursive: true });
|
|
226
|
+
run('npx', ['--yes', 'create-expo-app@latest', 'mobile', '--template', 'blank-typescript', '--no-install'], join(target, 'apps'), { quiet: true });
|
|
227
|
+
const m = join(target, 'apps/mobile');
|
|
228
|
+
// Keep the SDK template's own app.json values (icons, splash, platform defaults) as config/base.json.
|
|
229
|
+
const sdkBase = existsSync(join(m, 'app.json')) ? readJson(join(m, 'app.json')).expo ?? {} : {};
|
|
230
|
+
for (const k of ['name', 'slug', 'plugins']) delete sdkBase[k];
|
|
231
|
+
for (const f of ['App.tsx', 'index.ts', 'app.json', '.gitignore', 'LICENSE']) rmSync(join(m, f), { force: true });
|
|
232
|
+
rmSync(join(m, '.git'), { recursive: true, force: true });
|
|
233
|
+
copyTemplate(join(TPL, 'mobile/common'), m, vars);
|
|
234
|
+
writeJson(join(m, 'config/base.json'), sdkBase);
|
|
235
|
+
copyTemplate(join(TPL, `mobile/nav-${a.nav}`), m, vars);
|
|
236
|
+
copyTemplate(join(TPL, `mobile/${state}`), m, vars);
|
|
237
|
+
copyTemplate(join(TPL, ads ? 'mobile/ads' : 'mobile/noads'), m, vars);
|
|
238
|
+
const mp = readJson(join(m, 'package.json'));
|
|
239
|
+
mp.name = `${scope}/mobile`;
|
|
240
|
+
mp.main = 'expo-router/entry';
|
|
241
|
+
mp.scripts = { ...mp.scripts, start: 'expo start', android: 'expo run:android', ios: 'expo run:ios', web: 'expo start --web', prebuild: 'expo prebuild --clean', typecheck: 'tsc --noEmit' };
|
|
242
|
+
mp.dependencies = { ...mp.dependencies, [`${scope}/theme`]: '*', [`${scope}/shared`]: '*' };
|
|
243
|
+
writeJson(join(m, 'package.json'), mp);
|
|
244
|
+
s.stop('Expo app created');
|
|
245
|
+
|
|
246
|
+
// 3. Web: latest Next.js (App Router, TS, Tailwind, src/), then our layer
|
|
247
|
+
if (web) {
|
|
248
|
+
s.start('Creating the Next.js app (latest)');
|
|
249
|
+
run('npx', ['--yes', 'create-next-app@latest', 'web', '--ts', '--app', '--eslint', '--tailwind', '--src-dir', '--import-alias', '@/*', '--use-npm', '--skip-install', '--disable-git', '--yes'], join(target, 'apps'), { quiet: true });
|
|
250
|
+
const w = join(target, 'apps/web');
|
|
251
|
+
rmSync(join(w, '.gitignore'), { force: true });
|
|
252
|
+
rmSync(join(w, 'README.md'), { force: true });
|
|
253
|
+
rmSync(join(w, 'src/app/page.tsx'), { force: true }); // replaced by src/app/(site)/page.tsx
|
|
254
|
+
copyTemplate(join(TPL, 'web'), w, vars);
|
|
255
|
+
// Keep Next's own generated next.config.ts (it changes between versions) and only add
|
|
256
|
+
// transpilePackages so the shared TypeScript packages compile.
|
|
257
|
+
const nc = join(w, 'next.config.ts');
|
|
258
|
+
const tp = `transpilePackages: ['${scope}/theme', '${scope}/shared'],`;
|
|
259
|
+
if (existsSync(nc) && /const nextConfig: NextConfig = \{/.test(readFileSync(nc, 'utf8'))) {
|
|
260
|
+
writeFileSync(nc, readFileSync(nc, 'utf8').replace(/const nextConfig: NextConfig = \{/, (x) => `${x}\n ${tp}`));
|
|
261
|
+
} else {
|
|
262
|
+
writeFileSync(nc, `import type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n ${tp}\n};\n\nexport default nextConfig;\n`);
|
|
263
|
+
}
|
|
264
|
+
const wp = readJson(join(w, 'package.json'));
|
|
265
|
+
wp.name = `${scope}/web`;
|
|
266
|
+
wp.scripts = { ...wp.scripts, typecheck: 'next typegen && tsc --noEmit' }; // typegen writes LayoutProps/RouteContext types
|
|
267
|
+
wp.dependencies = { ...wp.dependencies, [`${scope}/theme`]: '*', [`${scope}/shared`]: '*' };
|
|
268
|
+
writeJson(join(w, 'package.json'), wp);
|
|
269
|
+
s.stop('Next.js app created');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
writeThemeAndFonts(target, a);
|
|
273
|
+
const pluginList = (added = []) => [...new Set(['expo-router', ...added, ...(ads ? ['react-native-google-mobile-ads'] : []), './plugins/withSigning.js'])];
|
|
274
|
+
|
|
275
|
+
// 4. Install: one root install, then SDK-matched native packages via expo install
|
|
276
|
+
if (install) {
|
|
277
|
+
s.start('Installing (one install for all workspaces)');
|
|
278
|
+
run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
279
|
+
s.message('Adding Expo packages matched to this SDK');
|
|
280
|
+
const expoPkgs = ['expo-router', 'expo-linking', 'expo-constants', 'expo-status-bar', 'expo-haptics', 'expo-system-ui', 'expo-font', '@expo/vector-icons', 'react-native-safe-area-context', 'react-native-screens', 'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', '@react-native-async-storage/async-storage', 'react-dom', 'react-native-web'];
|
|
281
|
+
if (ads) expoPkgs.push('react-native-google-mobile-ads');
|
|
282
|
+
if (FONTS[a.font].pkg) expoPkgs.push(FONTS[a.font].pkg);
|
|
283
|
+
const expoArgs = ['expo', 'install', ...expoPkgs, '--', '--no-audit', '--no-fund', '--loglevel=error'];
|
|
284
|
+
// expo install adds config plugins itself, but can't edit app.config.ts. Give it a temporary
|
|
285
|
+
// app.json, then copy the plugins it added into config/plugins.json (read by app.config.ts).
|
|
286
|
+
renameSync(join(m, 'app.config.ts'), join(m, 'app.config.ts.tmp'));
|
|
287
|
+
writeJson(join(m, 'app.json'), { expo: { name: displayName, slug, plugins: [] } });
|
|
288
|
+
try {
|
|
289
|
+
run('npx', expoArgs, m, { quiet: true });
|
|
290
|
+
} catch {
|
|
291
|
+
// Expo's version API unreachable (proxy/offline): use the versions bundled with the installed SDK.
|
|
292
|
+
s.message("Expo API unreachable, using the SDK's bundled version list");
|
|
293
|
+
run('npx', expoArgs, m, { quiet: true, env: { EXPO_OFFLINE: '1' } });
|
|
294
|
+
}
|
|
295
|
+
const added = (readJson(join(m, 'app.json')).expo.plugins ?? []).map((x) => (Array.isArray(x) ? x[0] : x));
|
|
296
|
+
rmSync(join(m, 'app.json'));
|
|
297
|
+
renameSync(join(m, 'app.config.ts.tmp'), join(m, 'app.config.ts'));
|
|
298
|
+
const statePkgs = state === 'redux' ? ['@reduxjs/toolkit', 'react-redux', '@tanstack/react-query'] : ['zustand', '@tanstack/react-query'];
|
|
299
|
+
run('npm', ['install', ...statePkgs, '-w', `${scope}/mobile`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
300
|
+
run('npm', ['install', 'zod', '-w', `${scope}/shared`, '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
301
|
+
run('npm', ['install', '-D', 'prettier', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
302
|
+
// Now that the packages exist, turn their config plugins on.
|
|
303
|
+
writeJson(join(m, 'config/plugins.json'), pluginList(added));
|
|
304
|
+
if (web) {
|
|
305
|
+
// One React for the whole repo: pin web's react/react-dom to the version Expo installed,
|
|
306
|
+
// otherwise npm can install two Reacts and hooks break ("Invalid hook call").
|
|
307
|
+
const rv = readJson(join(m, 'package.json')).dependencies.react;
|
|
308
|
+
const w = join(target, 'apps/web');
|
|
309
|
+
const wp = readJson(join(w, 'package.json'));
|
|
310
|
+
wp.dependencies.react = rv;
|
|
311
|
+
wp.dependencies['react-dom'] = rv;
|
|
312
|
+
writeJson(join(w, 'package.json'), wp);
|
|
313
|
+
const rootPkg2 = readJson(join(target, 'package.json'));
|
|
314
|
+
rootPkg2.overrides = { ...(rootPkg2.overrides ?? {}), react: rv, 'react-dom': rv };
|
|
315
|
+
writeJson(join(target, 'package.json'), rootPkg2);
|
|
316
|
+
run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], target, { quiet: true });
|
|
317
|
+
}
|
|
318
|
+
s.stop('Installed');
|
|
319
|
+
} else {
|
|
320
|
+
writeJson(join(m, 'config/plugins.json'), pluginList());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 5. Git (uses your git name/email; falls back to a neutral author if none is set)
|
|
324
|
+
if (!has('--no-git')) {
|
|
325
|
+
try {
|
|
326
|
+
run('git', ['init', '-b', 'main'], target, { quiet: true });
|
|
327
|
+
run('git', ['add', '-A'], target, { quiet: true });
|
|
328
|
+
try { run('git', ['commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true }); }
|
|
329
|
+
catch { run('git', ['-c', 'user.name=create-techtiten-app', '-c', 'user.email=noreply@techtiten.com', 'commit', '-m', 'Initial commit from create-techtiten-app', '--no-verify'], target, { quiet: true }); }
|
|
330
|
+
} catch { p.log.warn('git init skipped (git missing or commit failed). Run git init yourself.'); }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const rel = relative(process.cwd(), target) || '.';
|
|
334
|
+
p.note([
|
|
335
|
+
`cd ${rel}`,
|
|
336
|
+
'npm run mobile # Expo dev server (press a for Android)',
|
|
337
|
+
web ? 'npm run web # Next.js on http://localhost:3000 (/admin, /api/health)' : null,
|
|
338
|
+
'npm run typecheck # all workspaces',
|
|
339
|
+
'npm run format # prettier',
|
|
340
|
+
ads ? 'Ads need a dev build (npm run android), not Expo Go.' : null,
|
|
341
|
+
].filter(Boolean).join('\n'), 'Next steps');
|
|
342
|
+
p.outro(`${pc.green('Done.')} ${displayName} is ready in ${pc.cyan(rel)}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
main().catch((e) => fail(e.message));
|
package/bin/presets.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Theme presets and fonts. Add one here and it shows up in the prompts and in --help.
|
|
2
|
+
|
|
3
|
+
const neutrals = {
|
|
4
|
+
cool: {
|
|
5
|
+
light: { background: '#F7F8FA', surface: '#FFFFFF', surfaceAlt: '#EEF0F3', border: '#E2E5EA', text: '#0B0D12', textMuted: '#5B6270' },
|
|
6
|
+
dark: { background: '#0B0B12', surface: '#15151F', surfaceAlt: '#1D1D2A', border: '#2A2A38', text: '#ECEDF2', textMuted: '#9A9CAB' },
|
|
7
|
+
},
|
|
8
|
+
navy: {
|
|
9
|
+
light: { background: '#F5F8FC', surface: '#FFFFFF', surfaceAlt: '#EAF0F8', border: '#DCE4EF', text: '#0A1324', textMuted: '#55627A' },
|
|
10
|
+
dark: { background: '#0A1020', surface: '#121A2E', surfaceAlt: '#1A2440', border: '#26314F', text: '#E8EEF8', textMuted: '#93A0BA' },
|
|
11
|
+
},
|
|
12
|
+
warm: {
|
|
13
|
+
light: { background: '#FBF8F5', surface: '#FFFFFF', surfaceAlt: '#F4EEE8', border: '#EADFD5', text: '#1A120D', textMuted: '#6E6058' },
|
|
14
|
+
dark: { background: '#140F0C', surface: '#1E1713', surfaceAlt: '#2A201A', border: '#3A2D25', text: '#F3ECE6', textMuted: '#AE9F95' },
|
|
15
|
+
},
|
|
16
|
+
plum: {
|
|
17
|
+
light: { background: '#F8F7FC', surface: '#FFFFFF', surfaceAlt: '#EFECF9', border: '#E1DCF2', text: '#120E22', textMuted: '#5E5875' },
|
|
18
|
+
dark: { background: '#0E0B1A', surface: '#171328', surfaceAlt: '#211B37', border: '#2E2649', text: '#EEEAFB', textMuted: '#A29BBF' },
|
|
19
|
+
},
|
|
20
|
+
lcd: {
|
|
21
|
+
light: { background: '#C7D3A6', surface: '#BCC994', surfaceAlt: '#AFBD86', border: '#8F9D68', text: '#1E2A14', textMuted: '#4A5832' },
|
|
22
|
+
dark: { background: '#10160C', surface: '#182012', surfaceAlt: '#212B19', border: '#33402A', text: '#B8E07A', textMuted: '#7E9A57' },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const fixed = { danger: '#E5484D', warning: '#E9A049', success: '#2EAD5B' };
|
|
27
|
+
|
|
28
|
+
// brand per mode: [brand, brandStrong, onBrand]
|
|
29
|
+
export const THEMES = {
|
|
30
|
+
techtiten: { label: 'Techtiten', hint: 'mint green + purple (FitPrep / EzySplit feel)', neutral: 'cool', light: ['#00A372', '#008A60', '#FFFFFF'], dark: ['#00C389', '#00A372', '#04110C'], accent: '#7C5CFF' },
|
|
31
|
+
ocean: { label: 'Ocean', hint: 'calm blue, trusted (finance, productivity)', neutral: 'navy', light: ['#2F6FED', '#1F57C8', '#FFFFFF'], dark: ['#4D8DFF', '#2F6FED', '#06122A'], accent: '#00B8D9' },
|
|
32
|
+
sunset: { label: 'Sunset', hint: 'orange + pink, energetic (food, fitness)', neutral: 'warm', light: ['#F2552C', '#D4401B', '#FFFFFF'], dark: ['#FF6B4A', '#F2552C', '#1A0B06'], accent: '#FF4F8B' },
|
|
33
|
+
royal: { label: 'Royal', hint: 'violet, premium (education, lifestyle)', neutral: 'plum', light: ['#6A4CF0', '#5435D6', '#FFFFFF'], dark: ['#8B70FF', '#6A4CF0', '#0E0820'], accent: '#FF5CA8' },
|
|
34
|
+
mono: { label: 'Mono', hint: 'black & white, minimal (tools, portfolio)', neutral: 'cool', light: ['#111317', '#000000', '#FFFFFF'], dark: ['#F2F3F5', '#FFFFFF', '#0B0B12'], accent: '#7A808C' },
|
|
35
|
+
retro: { label: 'Retro LCD', hint: 'old-phone green screen (games, nostalgia)', neutral: 'lcd', light: ['#2F3B1E', '#1E2A14', '#C7D3A6'], dark: ['#9BD14A', '#B8E07A', '#10160C'], accent: '#5E7A2E' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Mobile: @expo-google-fonts/<pkg> exports; web: next/font/google import name + options.
|
|
39
|
+
export const FONTS = {
|
|
40
|
+
system: { label: 'System', hint: 'San Francisco / Roboto, nothing to load' },
|
|
41
|
+
inter: { label: 'Inter', hint: 'clean UI font', pkg: '@expo-google-fonts/inter', weights: { regular: 'Inter_400Regular', medium: 'Inter_500Medium', bold: 'Inter_700Bold', black: 'Inter_800ExtraBold' }, next: 'Inter', nextWeights: null },
|
|
42
|
+
poppins: { label: 'Poppins', hint: 'round and friendly (consumer apps)', pkg: '@expo-google-fonts/poppins', weights: { regular: 'Poppins_400Regular', medium: 'Poppins_500Medium', bold: 'Poppins_700Bold', black: 'Poppins_800ExtraBold' }, next: 'Poppins', nextWeights: ['400', '500', '700', '800'] },
|
|
43
|
+
'space-grotesk': { label: 'Space Grotesk', hint: 'techy, modern', pkg: '@expo-google-fonts/space-grotesk', weights: { regular: 'SpaceGrotesk_400Regular', medium: 'SpaceGrotesk_500Medium', bold: 'SpaceGrotesk_700Bold', black: 'SpaceGrotesk_700Bold' }, next: 'Space_Grotesk', nextWeights: null },
|
|
44
|
+
pixel: { label: 'Press Start 2P', hint: 'pixel/8-bit (retro games)', scale: 0.72, pkg: '@expo-google-fonts/press-start-2p', weights: { regular: 'PressStart2P_400Regular', medium: 'PressStart2P_400Regular', bold: 'PressStart2P_400Regular', black: 'PressStart2P_400Regular' }, next: 'Press_Start_2P', nextWeights: ['400'] },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const hex = (h) => h.replace('#', '').padEnd(6, '0').slice(0, 6);
|
|
48
|
+
const toRgb = (h) => [0, 2, 4].map((i) => parseInt(hex(h).slice(i, i + 2), 16));
|
|
49
|
+
const toHex = (rgb) => '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
50
|
+
const shade = (h, f) => toHex(toRgb(h).map((v) => (f < 0 ? v * (1 + f) : v + (255 - v) * f)));
|
|
51
|
+
const luminance = (h) => { const [r, g, b] = toRgb(h).map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
|
52
|
+
export const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test(v ?? '');
|
|
53
|
+
|
|
54
|
+
/** { light: {...}, dark: {...} } colors for packages/theme. `brand` (#RRGGBB) overrides the preset's brand. */
|
|
55
|
+
export function buildColors(themeKey, brand) {
|
|
56
|
+
const t = THEMES[themeKey];
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const mode of ['light', 'dark']) {
|
|
59
|
+
let [b, strong, on] = t[mode];
|
|
60
|
+
if (brand) {
|
|
61
|
+
b = '#' + hex(brand).toUpperCase();
|
|
62
|
+
strong = shade(b, mode === 'light' ? -0.15 : 0.15);
|
|
63
|
+
on = luminance(b) > 0.45 ? '#0B0D12' : '#FFFFFF';
|
|
64
|
+
}
|
|
65
|
+
out[mode] = { ...neutrals[t.neutral][mode], brand: b, brandStrong: strong, onBrand: on, accent: t.accent, ...fixed };
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-techtiten-app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Scaffold a Techtiten app: Expo (Android/iOS) + optional Next.js (web, admin panel, API) with a shared theme and shared logic. Always on the latest Expo and Next.js.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-techtiten-app": "bin/index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"templates"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.19"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"expo",
|
|
18
|
+
"react-native",
|
|
19
|
+
"nextjs",
|
|
20
|
+
"monorepo",
|
|
21
|
+
"template",
|
|
22
|
+
"create-app",
|
|
23
|
+
"zustand",
|
|
24
|
+
"redux"
|
|
25
|
+
],
|
|
26
|
+
"author": "Arun Kumar (Techtiten)",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/codewitharun/create-techtiten-app.git"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@clack/prompts": "^1.8.1",
|
|
34
|
+
"picocolors": "^1.1.1"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/codewitharun/create-techtiten-app#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/codewitharun/create-techtiten-app/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
import { BannerAd, BannerAdSize, TestIds } from 'react-native-google-mobile-ads';
|
|
3
|
+
|
|
4
|
+
// Test ads in development, your real unit id in release builds (EXPO_PUBLIC_ADMOB_BANNER_ID).
|
|
5
|
+
// Ads need a dev build (npm run android), not Expo Go.
|
|
6
|
+
const UNIT_ID = __DEV__ ? TestIds.ADAPTIVE_BANNER : process.env.EXPO_PUBLIC_ADMOB_BANNER_ID ?? TestIds.ADAPTIVE_BANNER;
|
|
7
|
+
|
|
8
|
+
export function Banner() {
|
|
9
|
+
return (
|
|
10
|
+
<View style={{ alignItems: 'center' }}>
|
|
11
|
+
<BannerAd unitId={UNIT_ID} size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER} />
|
|
12
|
+
</View>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ExpoConfig } from 'expo/config';
|
|
2
|
+
import base from './config/base.json';
|
|
3
|
+
import pluginNames from './config/plugins.json';
|
|
4
|
+
|
|
5
|
+
// config/base.json = what this Expo SDK's own template ships (icons, splash, platform defaults),
|
|
6
|
+
// saved by create-techtiten-app. Our settings go on top, so new SDK defaults are never lost.
|
|
7
|
+
// config/plugins.json = config plugins of the installed packages.
|
|
8
|
+
|
|
9
|
+
// versionCode/buildNumber come from CI (BUILD_NUMBER = GitHub run number): every Play upload is higher.
|
|
10
|
+
const buildNumber = Number(process.env.BUILD_NUMBER ?? 1);
|
|
11
|
+
|
|
12
|
+
// AdMob app ids: Google's TEST ids unless ADMOB_ANDROID_APP_ID / ADMOB_IOS_APP_ID are set (CI or .env).
|
|
13
|
+
const pluginOptions: Record<string, object> = {
|
|
14
|
+
'react-native-google-mobile-ads': {
|
|
15
|
+
androidAppId: process.env.ADMOB_ANDROID_APP_ID ?? 'ca-app-pub-3940256099942544~3347511713',
|
|
16
|
+
iosAppId: process.env.ADMOB_IOS_APP_ID ?? 'ca-app-pub-3940256099942544~1458002511',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const plugins: ExpoConfig['plugins'] = (pluginNames as string[]).map((name) => (pluginOptions[name] ? [name, pluginOptions[name]] : name));
|
|
20
|
+
|
|
21
|
+
const sdkDefaults = base as Partial<ExpoConfig>;
|
|
22
|
+
|
|
23
|
+
const config: ExpoConfig = {
|
|
24
|
+
...sdkDefaults,
|
|
25
|
+
name: '__APP_NAME__',
|
|
26
|
+
slug: '__APP_SLUG__',
|
|
27
|
+
scheme: '__APP_SLUG__',
|
|
28
|
+
version: '1.0.0',
|
|
29
|
+
userInterfaceStyle: 'automatic',
|
|
30
|
+
ios: { ...sdkDefaults.ios, bundleIdentifier: '__PACKAGE_ID__', buildNumber: String(buildNumber) },
|
|
31
|
+
android: { ...sdkDefaults.android, package: '__PACKAGE_ID__', versionCode: buildNumber },
|
|
32
|
+
plugins,
|
|
33
|
+
experiments: { ...sdkDefaults.experiments, typedRoutes: true },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default config;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Bug fixes and improvements.
|