Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
# React Native Application Template
# React Native App Template

Instructions for contributors (and AI agents) working in this repository. These take
precedence over generic assumptions about how a React Native project is laid out.

## What this repository is
## What this is

A production-oriented mobile app project template built with **React Native** and
**TypeScript** (Expo, with Continuous Native Generation). It ships a layered
architecture, MVVM-style state management, dependency injection through a composition
root, a set of cross-cutting concerns wired end to end, and a runnable sample feature
so a new app can start from a working, tested baseline.
A production-oriented mobile app built with **React Native** and **TypeScript**
(Expo, with Continuous Native Generation). It ships a layered architecture,
MVVM-style state management, dependency injection through a composition root, a set
of cross-cutting concerns wired end to end, and a runnable sample feature so the app
starts from a working, tested baseline.

<!-- template-only:begin -->
This repository is the **template** those apps are generated from. Run `yarn generate`
to stamp out a new app — identifiers substituted, template-only scaffolding removed;
see [doc/ProjectGenerator.md](doc/ProjectGenerator.md).
<!-- template-only:end -->

## Architecture

Expand All @@ -27,7 +33,7 @@ The app is organized into layers, and within each layer code is grouped **by fea
- **Dependency injection** wires the layers together: services are declared as
interfaces and constructed in a single composition root
(`src/framework/composition/createServices.ts`) — there is no DI container.
- **Cross-cutting concerns** the template ships: environments/configuration,
- **Cross-cutting concerns** the app ships: environments/configuration,
logging, a diagnostics overlay, forced update, kill switch, localization, HTTP,
local storage, serialization, navigation, and automated testing.
- **A runnable sample feature** — the "Dad Jokes" app — demonstrates a full vertical
Expand Down Expand Up @@ -185,7 +191,7 @@ Operational conventions (see the matching `doc/` pages):
Platform-integration conventions (see the matching `doc/` pages):

- **Opt-in vendor SDKs.** Firebase Remote Config and Bugsee are **not** default
dependencies — the base template ships (and prebuilds) without them. Each SDK is
dependencies — the app ships (and prebuilds) without them. Each SDK is
loaded by a **literal guarded `require`** inside a single native gateway
(`FirebaseRemoteConfigGateway`, `NativeBugseeGateway`), reachable **only** from
`src/framework/composition/platformIntegrations.ts`, which the app entry imports
Expand Down
99 changes: 99 additions & 0 deletions cli/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
renderAppReadme,
resolveIdentifiers,
run,
sanitizeIosScheme,
slugify,
stripTemplateOnlyBlocks,
TEMPLATE_IDENTIFIERS,
Expand Down Expand Up @@ -68,6 +69,21 @@ describe('slugify', () => {
});
});

describe('sanitizeIosScheme', () => {
it("mirrors Expo's sanitizedName: strips spaces/punctuation and diacritics", () => {
// The template's own name and the value hard-coded as `iosScheme`.
expect(sanitizeIosScheme('React Native App Template')).toBe('ReactNativeAppTemplate');
expect(sanitizeIosScheme('Acme App')).toBe('AcmeApp');
// Faithful to Expo: precomposed accented letters are non-word chars and are
// dropped by the first strip, so they vanish rather than de-accenting.
expect(sanitizeIosScheme('Café Münster')).toBe('CafMnster');
});

it('falls back to `app` when nothing alphanumeric survives', () => {
expect(sanitizeIosScheme('!!!')).toBe('app');
});
});

describe('deriveInternalBundleId', () => {
it('inserts an internal segment before the last', () => {
expect(deriveInternalBundleId('com.acme.acmeapp')).toBe('com.acme.internal.acmeapp');
Expand Down Expand Up @@ -124,6 +140,17 @@ describe('buildReplacements', () => {
froms.indexOf(TEMPLATE_IDENTIFIERS.baseBundleId),
);
});

it('includes a rule for the derived iOS scheme name', () => {
const rule = buildReplacements(ACME).find((r) => r.from === 'ReactNativeAppTemplate');
expect(rule).toEqual({ from: 'ReactNativeAppTemplate', to: 'AcmeApp' });
});

it('omits the scheme rule when the sanitized name is unchanged', () => {
const sameName: Identifiers = { ...ACME, appName: TEMPLATE_IDENTIFIERS.appName };
const froms = buildReplacements(sameName).map((r) => r.from);
expect(froms).not.toContain('ReactNativeAppTemplate');
});
});

describe('applyReplacements', () => {
Expand Down Expand Up @@ -253,6 +280,13 @@ describe('generate (filesystem)', () => {
join(root, 'build', 'pipeline.yml'),
`staging: ${TEMPLATE_IDENTIFIERS.internalBundleId}.jks\nprod: ${TEMPLATE_IDENTIFIERS.baseBundleId}.jks\n`,
);
// The iOS scheme/workspace name expo derives from the app name — hard-coded
// here, and NOT one of the four identifier strings, so a dedicated rule must
// rewrite it (see sanitizeIosScheme).
writeFileSync(
join(root, 'build', 'variables.yml'),
`iosScheme: '${sanitizeIosScheme(TEMPLATE_IDENTIFIERS.appName)}'\n`,
);
// Template-only CI steps template: removed, but the rest of build/ is kept.
writeFileSync(join(root, 'build', 'templates', 'template-validation.yml'), 'steps: []\n');
// The main pipeline carries a Template_Validation stage bracketed by markers;
Expand Down Expand Up @@ -304,6 +338,42 @@ describe('generate (filesystem)', () => {
join(root, 'cli', 'generate.ts'),
`const from = '${TEMPLATE_IDENTIFIERS.appName}';`,
);
// CLAUDE.md and the doc index self-identify as a template. The title uses the
// Expo `name` (renamed by substitution) and the template framing sits in a
// `template-only` block (scrubbed in place), so a generated app reads as an app.
writeFileSync(
join(root, 'CLAUDE.md'),
[
`# ${TEMPLATE_IDENTIFIERS.appName}`,
'',
'Contributor and AI-agent instructions.',
'',
'<!-- template-only:begin -->',
'This repository is the **template** new apps are generated from; run `yarn generate`.',
'<!-- template-only:end -->',
'',
'## Architecture',
'',
'Layer boundaries that every generated app keeps.',
'',
].join('\n'),
);
writeFileSync(
join(root, 'doc', 'README.md'),
[
'# Documentation',
'',
`Per-concern documentation for ${TEMPLATE_IDENTIFIERS.appName}.`,
'',
'| Topic | Page |',
'| ----- | ---- |',
'| CI pipeline | [AzurePipelines.md](AzurePipelines.md) |',
'<!-- template-only:begin -->',
'| Project generator CLI | [ProjectGenerator.md](ProjectGenerator.md) |',
'<!-- template-only:end -->',
'',
].join('\n'),
);
writeFileSync(join(root, 'README.md'), '# Template');
});

Expand All @@ -326,6 +396,10 @@ describe('generate (filesystem)', () => {
expect(readFileSync(join(root, 'build', 'pipeline.yml'), 'utf8')).toBe(
'staging: com.acme.internal.acmeapp.jks\nprod: com.acme.acmeapp.jks\n',
);
// The iOS scheme is renamed too — `Acme App` sanitizes to `AcmeApp`.
expect(readFileSync(join(root, 'build', 'variables.yml'), 'utf8')).toBe(
"iosScheme: 'AcmeApp'\n",
);

// Untouched binary.
expect(readFileSync(join(root, 'icon.png'), 'utf8')).toBe(TEMPLATE_IDENTIFIERS.appName);
Expand All @@ -340,6 +414,9 @@ describe('generate (filesystem)', () => {
const content = readFileSync(file, 'utf8');
expect(content).not.toContain('reactnativeapptemplate');
expect(content).not.toContain('React Native App Template');
// The sanitized iOS scheme name (case-sensitive; not caught by the
// lowercase check above) must not survive either.
expect(content).not.toContain('ReactNativeAppTemplate');
}
});

Expand Down Expand Up @@ -391,6 +468,28 @@ describe('generate (filesystem)', () => {
);
});

it('rewrites CLAUDE.md and the doc index so the app no longer reads as a template', () => {
const result = generate(root, ACME);

const claude = readFileSync(join(root, 'CLAUDE.md'), 'utf8');
// The Expo-name title is renamed, and the template framing is gone with its markers.
expect(claude).toContain('# Acme App');
expect(claude).not.toContain('template-only');
expect(claude).not.toContain('generated from');
// The architecture guidance every app keeps survives the scrub.
expect(claude).toContain('## Architecture');
expect(claude).toContain('Layer boundaries that every generated app keeps.');

const docIndex = readFileSync(join(root, 'doc', 'README.md'), 'utf8');
expect(docIndex).toContain('Per-concern documentation for Acme App.');
// The link to the deleted generator page is gone; the real rows remain.
expect(docIndex).not.toContain('ProjectGenerator');
expect(docIndex).not.toContain('template-only');
expect(docIndex).toContain('[AzurePipelines.md](AzurePipelines.md)');

expect(result.unwired).toEqual(expect.arrayContaining(['CLAUDE.md', 'doc/README.md']));
});

it('un-wires the generator from package.json and jest.config.js when cli is removed', () => {
const result = generate(root, ACME);
expect(result.unwired).toEqual(expect.arrayContaining(['package.json', 'jest.config.js']));
Expand Down
71 changes: 61 additions & 10 deletions cli/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,34 @@ export function slugify(value: string): string {
.replace(/^-+|-+$/g, '');
}

/**
* The iOS Xcode scheme/workspace/project name that `expo prebuild` derives from
* the app `name`. Mirrors Expo's `sanitizedName`
* (`@expo/config-plugins` → `ios/utils/Xcodeproj`): strip every non-word
* character and underscore, then drop combining diacritics. So
* `React Native App Template` -> `ReactNativeAppTemplate` and `Acme App` ->
* `AcmeApp`.
*
* The generated `ios/` project isn't committed (Continuous Native Generation),
* but this derived name IS hard-coded in committed files — the `iosScheme`
* pipeline variable in `build/variables.yml` and the `xcodebuild` examples in
* the docs — so a rename must rewrite it too. Miss it and the iOS build runs
* `xcodebuild -workspace ios/<oldName>.xcworkspace`, which prebuild never
* generated under that name ("… .xcworkspace does not exist").
*
* Faithful to Expo for any name with latin alphanumerics (the realistic case).
* A name that sanitizes to empty (e.g. all non-ASCII) falls back to `app` here,
* where Expo would first try a transliterated slug — an edge case a rename this
* tool targets does not hit.
*/
export function sanitizeIosScheme(name: string): string {
const sanitized = name
.replace(/[\W_]+/g, '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
return sanitized || 'app';
}

const BUNDLE_ID_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;

Expand Down Expand Up @@ -262,12 +290,29 @@ export interface Replacement {
* can never partially consume a longer one.
*/
export function buildReplacements(identifiers: Identifiers): Replacement[] {
return [
const replacements: Replacement[] = [
{ from: TEMPLATE_IDENTIFIERS.internalBundleId, to: identifiers.internalBundleId },
{ from: TEMPLATE_IDENTIFIERS.baseBundleId, to: identifiers.baseBundleId },
{ from: TEMPLATE_IDENTIFIERS.slug, to: identifiers.slug },
{ from: TEMPLATE_IDENTIFIERS.appName, to: identifiers.appName },
].sort((a, b) => b.from.length - a.from.length);
];

// The iOS Xcode scheme/workspace name `expo prebuild` derives from the app
// `name` (see sanitizeIosScheme) is a *fifth* identity string, distinct from
// the spaced display name and the bundle ids: `React Native App Template`
// sanitizes to `ReactNativeAppTemplate`, which none of the four rules above
// match. It is hard-coded in committed files (the `iosScheme` pipeline
// variable, the docs' `xcodebuild` examples), so rewrite it too — otherwise
// the generated app's iOS build points at a workspace prebuild never makes.
// Skip when the sanitized name is unchanged (a rename touching only the
// bundle id), so no no-op rule is emitted.
const fromScheme = sanitizeIosScheme(TEMPLATE_IDENTIFIERS.appName);
const toScheme = sanitizeIosScheme(identifiers.appName);
if (fromScheme !== toScheme) {
replacements.push({ from: fromScheme, to: toScheme });
}

return replacements.sort((a, b) => b.from.length - a.from.length);
}

/** Applies replacements to a string, returning the result and a match count. */
Expand Down Expand Up @@ -435,22 +480,26 @@ export function stripTemplateOnlyBlocks(content: string): string {

/**
* Files (relative to the project root) that carry `template-only` regions the
* generated app must not keep: the CI stage that runs this generator and its
* documentation. Deleting the generator's `cli` folder handles the code; these
* files instead have a marked region scrubbed in place so the rest of the file
* (the real pipeline, the rest of the doc) survives.
* generated app must not keep — e.g. the CI stage that runs this generator, the
* "this repository is a template" framing in `CLAUDE.md`, and the doc-index link
* to the (deleted) generator page. Deleting the generator's `cli` folder handles
* the code; these files instead have a marked region scrubbed in place so the
* rest of the file (the real pipeline, the architecture guidance, the rest of the
* index) survives.
*/
export const FILES_WITH_TEMPLATE_ONLY_BLOCKS: readonly string[] = [
'build/azure-pipelines.yml',
'doc/AzurePipelines.md',
'CLAUDE.md',
'doc/README.md',
];

/**
* Scrubs the `template-only` regions from {@link FILES_WITH_TEMPLATE_ONLY_BLOCKS}
* in place. Returns the files it changed. Idempotent — safe to run when the
* regions are already gone.
*/
export function stripTemplateOnlyPipelineStages(root: string): string[] {
export function stripTemplateOnlyBlocksInFiles(root: string): string[] {
const changed: string[] = [];
for (const relativePath of FILES_WITH_TEMPLATE_ONLY_BLOCKS) {
const target = join(root, relativePath);
Expand Down Expand Up @@ -529,10 +578,12 @@ export function generate(
}
// Once the cli folder is removed, its references in the tooling would break
// the quality gates; strip them so the generated project stays green. Also
// scrub the template-only CI stage (and its doc) that drive this generator —
// they reference the now-deleted cli/ and are meaningless in a generated app.
// scrub every `template-only` region — the CI stage that drives this
// generator, the doc-index link to the deleted generator page, and the
// "this repository is a template" framing in CLAUDE.md / the doc index — so a
// generated app reads as an app, not the template it was stamped from.
if (!dryRun) {
unwired = [...unwireGeneratorTooling(root), ...stripTemplateOnlyPipelineStages(root)];
unwired = [...unwireGeneratorTooling(root), ...stripTemplateOnlyBlocksInFiles(root)];
}
}

Expand Down
2 changes: 1 addition & 1 deletion doc/AppReviews.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The call never throws — a review prompt must not break the flow that triggered

From Presentation, read the thin
[`useAppReview`](../src/presentation/appReview/useAppReview.ts) hook and call it at
a genuinely positive moment. The template wires it to the **feedback form's
a genuinely positive moment. The app wires it to the **feedback form's
successful submit** (a completed flow) — see [Forms.md](Forms.md):

```ts
Expand Down
2 changes: 1 addition & 1 deletion doc/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Each layer's `README.md` states its rules; wiring is described in

## State management (MVVM)

Rather than ViewModel classes with observable properties, this template keeps the
Rather than ViewModel classes with observable properties, this app keeps the
**principle** — business state observable by the UI, logic testable without a
renderer — with two explicit data paths:

Expand Down
8 changes: 4 additions & 4 deletions doc/AzurePipelines.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Azure Pipelines

CI/CD for this template runs on **Azure Pipelines**. **EAS Build is not used**:
CI/CD for this app runs on **Azure Pipelines**. **EAS Build is not used**:
native builds run `expo prebuild` to generate the `android/` and `ios/` projects,
then build with **Gradle** and **Xcode** directly on the build agents.

Expand Down Expand Up @@ -174,7 +174,7 @@ one-time setup.
**Firebase is optional and off by default.** In `build/azure-pipelines.yml` the
`firebaseAndroidSecureFile` / `firebaseIosSecureFile` lines are **commented out**,
so the pipeline builds Firebase-free (no `FIREBASE_ENABLED`, no
`googleServicesFile`, a clean `expo prebuild`) — matching the base template, where
`googleServicesFile`, a clean `expo prebuild`) — matching the app default, where
Firebase is opt-in. The **presence of the secure-file name is the only switch**:
uncomment the line (and upload the file) to turn Firebase on for that build; there
is no separate flag to keep in sync. See [Enabling Firebase](#enabling-firebase).
Expand Down Expand Up @@ -408,7 +408,7 @@ The public repo commits **no vendor keys** — CI injects them at build time:
## Enabling Firebase

Firebase is **off by default** so a fresh clone (with no Firebase project yet)
builds and signs without it — matching the base template, where Firebase is
builds and signs without it — matching the app default, where Firebase is
opt-in. The build templates key entirely off whether a Firebase secure-file name
was passed: no name ⇒ no download, no `FIREBASE_ENABLED`, and `expo prebuild`
produces no Firebase native footprint.
Expand All @@ -422,7 +422,7 @@ To turn it on for a lane:
(e.g. `google-services-reactnative-internal.json`).
3. In `build/azure-pipelines.yml`, **uncomment** the matching
`firebaseAndroidSecureFile` / `firebaseIosSecureFile` line in that lane's job.
That single line is the switch — the template derives `FIREBASE_ENABLED` from
That single line is the switch — the build derives `FIREBASE_ENABLED` from
its presence, so there is no separate flag to set.

To turn it back off, re-comment the line (and optionally remove the secure file).
Expand Down
2 changes: 1 addition & 1 deletion doc/CrashReporting.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Crash & session reporting

Crash and session reporting through a single Access seam. The template ships
Crash and session reporting through a single Access seam. The app ships
**Bugsee for internal builds only** — it is billed per user, so it is only worth
running on internal distribution builds, never production.

Expand Down
2 changes: 1 addition & 1 deletion doc/DadJokes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The Dad Jokes sample feature

Dad Jokes is the template's **canonical vertical slice** — the reference every
Dad Jokes is the app's **canonical vertical slice** — the reference every
new feature is cloned from, following the recipe in
[CLAUDE.md](../CLAUDE.md#adding-a-feature-the-canonical-recipe). It exercises
every pattern the architecture names: interface + real + mock at the Access
Expand Down
2 changes: 1 addition & 1 deletion doc/DesignSystem.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Design system & theming

The template's visual language: design tokens (colors, spacing, radii,
The app's visual language: design tokens (colors, spacing, radii,
typography), light/dark support, and a small set of themed base components every
screen builds from.

Expand Down
Loading