@alfredmouelle/create-stack 0.1.1 → 0.1.2
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 +8 -13
- package/_stack/apps/next-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/next-base/.vscode/settings.json +35 -0
- package/_stack/apps/next-base/.zed/settings.json +45 -0
- package/_stack/apps/next-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/next-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/next-base/src/env.ts +2 -3
- package/_stack/apps/next-base/src/lib/date.ts +1 -1
- package/_stack/apps/next-base/src/server/auth/guards.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/server.ts +2 -2
- package/_stack/apps/next-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/next-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/port.ts +13 -20
- package/_stack/apps/next-base/src/server/email/core/render.ts +2 -2
- package/_stack/apps/next-base/src/server/email/factory.ts +7 -9
- package/_stack/apps/next-base/src/server/email/index.ts +1 -1
- package/_stack/apps/next-base/src/trpc/react.tsx +2 -2
- package/_stack/apps/next-base/src/trpc/server.ts +1 -1
- package/_stack/apps/tanstack-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/tanstack-base/.vscode/settings.json +35 -0
- package/_stack/apps/tanstack-base/.zed/settings.json +45 -0
- package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +1 -1
- package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/tanstack-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/tanstack-base/src/env.ts +2 -6
- package/_stack/apps/tanstack-base/src/lib/date.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/__root.tsx +1 -1
- package/_stack/apps/tanstack-base/src/routes/_authed.tsx +1 -4
- package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +1 -2
- package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +6 -7
- package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/port.ts +12 -22
- package/_stack/apps/tanstack-base/src/server/email/core/render.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/email/factory.ts +7 -8
- package/_stack/apps/tanstack-base/src/server/email/index.ts +1 -1
- package/_stack/packages/mailer/src/adapters/brevo/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/resend/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/config.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/index.ts +4 -5
- package/index.mjs +30 -47
- package/lib/build.mjs +6 -14
- package/lib/env.mjs +5 -6
- package/lib/foundations.mjs +35 -0
- package/lib/identity.mjs +4 -5
- package/lib/mailer.mjs +9 -13
- package/lib/paths.mjs +15 -0
- package/lib/scaffold.mjs +11 -11
- package/lib/strip.mjs +9 -24
- package/lib/util.mjs +8 -9
- package/package.json +1 -1
- package/_stack/packages/analytics/capability.json +0 -26
- package/_stack/packages/cache/capability.json +0 -21
- package/_stack/packages/error-tracking/capability.json +0 -21
- package/_stack/packages/jobs/capability.json +0 -26
- package/_stack/packages/logger/capability.json +0 -21
- package/_stack/packages/mailer/capability.json +0 -28
- package/_stack/packages/storage/capability.json +0 -32
- package/_stack/patterns/README.md +0 -58
- package/_stack/patterns/_baseline/env.ts +0 -31
- package/_stack/patterns/_baseline/tsconfig.json +0 -27
- package/_stack/patterns/better-auth/pattern.json +0 -73
- package/_stack/patterns/better-auth-next/pattern.json +0 -76
- package/_stack/patterns/data-table/pattern.json +0 -43
- package/_stack/patterns/drizzle/pattern.json +0 -61
- package/_stack/patterns/trpc/pattern.json +0 -61
- package/_stack/patterns/trpc-next/pattern.json +0 -64
- package/lib/manifests.mjs +0 -61
- /package/{_stack/patterns/_baseline → templates}/README-author.md +0 -0
- /package/{_stack/patterns/_baseline → templates}/biome.jsonc +0 -0
package/README.md
CHANGED
|
@@ -46,13 +46,12 @@ not exist yet. In non-interactive mode it is required.
|
|
|
46
46
|
| `--framework` | `tanstack` \| `next` | `tanstack` | Base app to fork. |
|
|
47
47
|
| `--foundations` | csv of `drizzle,trpc,better-auth,data-table` | all | Foundations to keep; the rest are stripped. |
|
|
48
48
|
| `--mailer` | `resend` \| `brevo` \| `ses` \| `none` | `resend` | Mailer provider. `none` is rejected when `better-auth` is kept. |
|
|
49
|
-
| `--caps` | csv of capability names | — | Extra capabilities to add afterwards via `add-capability` (see below). |
|
|
50
49
|
| `--no-install` | — | install on | Skip `pnpm install` + verification. |
|
|
51
50
|
| `--yes`, `-y` | — | — | Non-interactive with all defaults. |
|
|
52
51
|
|
|
53
|
-
Passing any of `--framework`, `--foundations`, `--mailer
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
Passing any of `--framework`, `--foundations`, `--mailer` or `--no-install`
|
|
53
|
+
(or `--yes`) switches the CLI to non-interactive mode; missing values fall back
|
|
54
|
+
to the defaults above.
|
|
56
55
|
|
|
57
56
|
### Dependency resolution
|
|
58
57
|
|
|
@@ -76,10 +75,6 @@ pnpm dlx @alfredmouelle/create-stack api --framework next \
|
|
|
76
75
|
|
|
77
76
|
# Minimal: Drizzle only, no mailer
|
|
78
77
|
pnpm dlx @alfredmouelle/create-stack db-svc --foundations drizzle --mailer none
|
|
79
|
-
|
|
80
|
-
# TanStack + auth, and queue up extra capabilities for later
|
|
81
|
-
pnpm dlx @alfredmouelle/create-stack app --foundations drizzle,trpc,better-auth \
|
|
82
|
-
--caps storage,jobs
|
|
83
78
|
```
|
|
84
79
|
|
|
85
80
|
## What you get
|
|
@@ -97,12 +92,12 @@ pnpm dlx @alfredmouelle/create-stack app --foundations drizzle,trpc,better-auth
|
|
|
97
92
|
Unselected foundations are removed cleanly (files, deps, env vars and wiring),
|
|
98
93
|
and the project is left **bootable and green** (typecheck + Biome).
|
|
99
94
|
|
|
100
|
-
##
|
|
95
|
+
## Adding more capabilities
|
|
101
96
|
|
|
102
|
-
|
|
103
|
-
logger, analytics, error-tracking, http
|
|
104
|
-
|
|
105
|
-
|
|
97
|
+
The base bakes in the **mailer** (chosen via `--mailer`). Other capabilities —
|
|
98
|
+
storage, jobs, cache, logger, analytics, error-tracking, http — are added *after*
|
|
99
|
+
scaffolding with the `add-capability` skill (it wires each adapter's env/config per
|
|
100
|
+
provider, which this CLI deliberately leaves out).
|
|
106
101
|
|
|
107
102
|
## After scaffolding
|
|
108
103
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsc --noEmit
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files.watcherExclude": {
|
|
3
|
+
"**/routeTree.gen.ts": true
|
|
4
|
+
},
|
|
5
|
+
"search.exclude": {
|
|
6
|
+
"**/routeTree.gen.ts": true
|
|
7
|
+
},
|
|
8
|
+
"files.readonlyInclude": {
|
|
9
|
+
"**/routeTree.gen.ts": true
|
|
10
|
+
},
|
|
11
|
+
"[javascript]": {
|
|
12
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
13
|
+
},
|
|
14
|
+
"[javascriptreact]": {
|
|
15
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
16
|
+
},
|
|
17
|
+
"[typescript]": {
|
|
18
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
19
|
+
},
|
|
20
|
+
"[typescriptreact]": {
|
|
21
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
22
|
+
},
|
|
23
|
+
"[json]": {
|
|
24
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
25
|
+
},
|
|
26
|
+
"[jsonc]": {
|
|
27
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
28
|
+
},
|
|
29
|
+
"[css]": {
|
|
30
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
31
|
+
},
|
|
32
|
+
"editor.codeActionsOnSave": {
|
|
33
|
+
"source.organizeImports.biome": "explicit"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lsp": {
|
|
3
|
+
"biome": {
|
|
4
|
+
"settings": {
|
|
5
|
+
"require_config_file": true,
|
|
6
|
+
"config_path": "biome.jsonc"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
"languages": {
|
|
12
|
+
"Markdown": {
|
|
13
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
14
|
+
},
|
|
15
|
+
"JSONC": {
|
|
16
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
17
|
+
},
|
|
18
|
+
"JSON": {
|
|
19
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
20
|
+
},
|
|
21
|
+
"HTML": { "formatter": { "language_server": { "name": "biome" } } },
|
|
22
|
+
"CSS": { "formatter": { "language_server": { "name": "biome" } } },
|
|
23
|
+
"JavaScript": {
|
|
24
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
25
|
+
"code_actions_on_format": {
|
|
26
|
+
"source.fixAll.biome": true,
|
|
27
|
+
"source.organizeImports.biome": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"TypeScript": {
|
|
31
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
32
|
+
"code_actions_on_format": {
|
|
33
|
+
"source.fixAll.biome": true,
|
|
34
|
+
"source.organizeImports.biome": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"TSX": {
|
|
38
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
39
|
+
"code_actions_on_format": {
|
|
40
|
+
"source.fixAll.biome": true,
|
|
41
|
+
"source.organizeImports.biome": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -2,7 +2,7 @@ import { Loader2 } from 'lucide-react'
|
|
|
2
2
|
import type { ComponentProps } from 'react'
|
|
3
3
|
import { cn } from '~/lib/utils'
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/** Spinning lucide loader icon. */
|
|
6
6
|
export function Spinner({ className, ...props }: ComponentProps<typeof Loader2>) {
|
|
7
7
|
return <Loader2 aria-hidden="true" className={cn('size-4 animate-spin', className)} {...props} />
|
|
8
8
|
}
|
|
@@ -7,9 +7,9 @@ import { createEmailTheme, type EmailTheme, type EmailThemeOverride } from './th
|
|
|
7
7
|
export interface EmailLayoutProps {
|
|
8
8
|
preview: string
|
|
9
9
|
children: React.ReactNode
|
|
10
|
-
/**
|
|
10
|
+
/** Theme override, merged onto default. */
|
|
11
11
|
theme?: EmailTheme | EmailThemeOverride
|
|
12
|
-
/** Footer year. Pass explicitly
|
|
12
|
+
/** Footer year. Pass explicitly for deterministic output. */
|
|
13
13
|
year?: number
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -14,7 +14,7 @@ export function EmailThemeProvider({ theme, children }: EmailThemeProviderProps)
|
|
|
14
14
|
return <EmailThemeContext.Provider value={theme}>{children}</EmailThemeContext.Provider>
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/** Active email theme; falls back to {@link defaultTheme}. */
|
|
18
18
|
export function useEmailTheme(): EmailTheme {
|
|
19
19
|
return useContext(EmailThemeContext)
|
|
20
20
|
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* or wrap a tree in `<EmailThemeProvider>`.
|
|
2
|
+
* Swappable email design tokens. Override a subset via {@link createEmailTheme}
|
|
3
|
+
* and pass to `<EmailLayout theme={...}>`, or wrap in `<EmailThemeProvider>`.
|
|
5
4
|
*/
|
|
6
5
|
export interface EmailTheme {
|
|
7
6
|
brand: {
|
|
8
|
-
/**
|
|
7
|
+
/** Header band + footer. */
|
|
9
8
|
name: string
|
|
10
|
-
/** Footer line (
|
|
9
|
+
/** Footer line (year prefixed automatically). */
|
|
11
10
|
footer: string
|
|
12
11
|
}
|
|
13
12
|
fontFamily: string
|
|
@@ -21,7 +20,7 @@ export interface EmailTheme {
|
|
|
21
20
|
border: string
|
|
22
21
|
borderSubtle: string
|
|
23
22
|
destructive: string
|
|
24
|
-
/** Text
|
|
23
|
+
/** Text on accent/destructive buttons. */
|
|
25
24
|
onAccent: string
|
|
26
25
|
}
|
|
27
26
|
}
|
|
@@ -52,7 +51,7 @@ export type EmailThemeOverride = {
|
|
|
52
51
|
colors?: Partial<EmailTheme['colors']>
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
/** Deep-merge
|
|
54
|
+
/** Deep-merge override onto base (defaults to {@link defaultTheme}). */
|
|
56
55
|
export function createEmailTheme(
|
|
57
56
|
override: EmailThemeOverride = {},
|
|
58
57
|
base: EmailTheme = defaultTheme,
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { createEnv } from '@t3-oss/env-core'
|
|
2
2
|
import * as v from 'valibot'
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/** Required in production, optional in dev/test. */
|
|
5
5
|
export const requiredInProduction = <T extends v.GenericSchema>(schema: T) =>
|
|
6
6
|
process.env.NODE_ENV === 'production' ? schema : v.optional(schema)
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Typed
|
|
10
|
-
* capabilities (add-capability) extend the `server` block and `runtimeEnv` with
|
|
9
|
+
* Typed env. Foundations + capabilities extend `server` and `runtimeEnv` with
|
|
11
10
|
* the keys they need.
|
|
12
11
|
*/
|
|
13
12
|
export const env = createEnv({
|
|
@@ -2,7 +2,7 @@ import 'server-only'
|
|
|
2
2
|
import { redirect } from 'next/navigation'
|
|
3
3
|
import { getSession } from '~/server/better-auth/server'
|
|
4
4
|
|
|
5
|
-
/** Require
|
|
5
|
+
/** Require signed-in user in a Server Component/page; else redirect. */
|
|
6
6
|
export async function requireAuth() {
|
|
7
7
|
const session = await getSession()
|
|
8
8
|
if (!session) redirect('/auth/sign-in')
|
|
@@ -36,7 +36,7 @@ export const auth = betterAuth({
|
|
|
36
36
|
},
|
|
37
37
|
socialProviders,
|
|
38
38
|
user: {
|
|
39
|
-
//
|
|
39
|
+
// Extra user columns here (mirror in auth.schema.ts):
|
|
40
40
|
// additionalFields: {
|
|
41
41
|
// role: { type: 'string', defaultValue: 'user', input: false },
|
|
42
42
|
// },
|
|
@@ -2,10 +2,10 @@ import { headers } from 'next/headers'
|
|
|
2
2
|
import { cache } from 'react'
|
|
3
3
|
import { auth } from '.'
|
|
4
4
|
|
|
5
|
-
/** Per-request cached session
|
|
5
|
+
/** Per-request cached session (uses cookie cache). */
|
|
6
6
|
export const getSession = cache(async () => auth.api.getSession({ headers: await headers() }))
|
|
7
7
|
|
|
8
|
-
/** Bypasses
|
|
8
|
+
/** Bypasses cookie cache; use for sensitive checks. */
|
|
9
9
|
export const getFreshSession = cache(async () =>
|
|
10
10
|
auth.api.getSession({
|
|
11
11
|
headers: await headers(),
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Drizzle schema barrel. One `export *` per `*.schema.ts`.
|
|
2
2
|
export * from './auth.schema'
|
|
@@ -3,11 +3,11 @@ import { config } from 'dotenv'
|
|
|
3
3
|
|
|
4
4
|
config({ path: ['.env.local', '.env'] })
|
|
5
5
|
|
|
6
|
-
// Deterministic
|
|
6
|
+
// Deterministic across runs.
|
|
7
7
|
faker.seed(42)
|
|
8
8
|
|
|
9
9
|
async function main() {
|
|
10
|
-
//
|
|
10
|
+
// Seed tables here (idempotent), via faker:
|
|
11
11
|
// const { db } = await import('~/server/db')
|
|
12
12
|
// await db.insert(user).values({
|
|
13
13
|
// id: faker.string.uuid(),
|
|
@@ -4,7 +4,7 @@ import { formatAddress } from '../../core/address'
|
|
|
4
4
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port'
|
|
5
5
|
import { ResendConfigSchema } from './config'
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Structural view of the Resend client we depend on (eases testing). */
|
|
8
8
|
export interface ResendClient {
|
|
9
9
|
emails: {
|
|
10
10
|
send(payload: ResendSendPayload): Promise<{
|
|
@@ -30,7 +30,7 @@ interface ResendSendPayload {
|
|
|
30
30
|
|
|
31
31
|
export interface ResendAdapterOptions {
|
|
32
32
|
apiKey: string
|
|
33
|
-
/** Inject a custom/mock client. Defaults to
|
|
33
|
+
/** Inject a custom/mock client. Defaults to real `Resend`. */
|
|
34
34
|
client?: ResendClient
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ function toAttachmentContent(content: Uint8Array | string): Buffer | string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
|
|
42
|
-
// Validate
|
|
42
|
+
// Validate early so a missing key fails at construction, not send().
|
|
43
43
|
const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
|
|
44
44
|
const client: ResendClient =
|
|
45
45
|
options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
|
|
@@ -2,7 +2,7 @@ import type { MailAddress, MailRecipient } from './port'
|
|
|
2
2
|
|
|
3
3
|
const ADDRESS_RE = /^\s*(.*?)\s*<([^>]+)>\s*$/
|
|
4
4
|
|
|
5
|
-
/** Coerce
|
|
5
|
+
/** Coerce recipient to {@link MailAddress}. */
|
|
6
6
|
export function normalizeAddress(input: MailRecipient): MailAddress {
|
|
7
7
|
if (typeof input !== 'string') return input
|
|
8
8
|
const match = input.match(ADDRESS_RE)
|
|
@@ -10,12 +10,12 @@ export function normalizeAddress(input: MailRecipient): MailAddress {
|
|
|
10
10
|
return { email: input.trim() }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/** Coerce
|
|
13
|
+
/** Coerce recipient(s) to normalized array. */
|
|
14
14
|
export function normalizeRecipients(input: MailRecipient | MailRecipient[]): MailAddress[] {
|
|
15
15
|
return (Array.isArray(input) ? input : [input]).map(normalizeAddress)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** Render
|
|
18
|
+
/** Render address to RFC string (`Name <email>`). */
|
|
19
19
|
export function formatAddress(address: MailAddress): string {
|
|
20
20
|
return address.name ? `${address.name} <${address.email}>` : address.email
|
|
21
21
|
}
|
|
@@ -1,41 +1,37 @@
|
|
|
1
1
|
import type { ReactElement } from 'react'
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/** Structured email address. */
|
|
4
4
|
export interface MailAddress {
|
|
5
5
|
email: string
|
|
6
6
|
name?: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* A recipient, either as a plain string (`"hi@acme.com"` or
|
|
11
|
-
* `"Acme <hi@acme.com>"`) or a structured {@link MailAddress}.
|
|
12
|
-
*/
|
|
9
|
+
/** Recipient: string (`"hi@acme.com"` / `"Acme <hi@acme.com>"`) or {@link MailAddress}. */
|
|
13
10
|
export type MailRecipient = string | MailAddress
|
|
14
11
|
|
|
15
12
|
export interface MailAttachment {
|
|
16
13
|
filename: string
|
|
17
|
-
/** Raw bytes or
|
|
14
|
+
/** Raw bytes or base64 string. */
|
|
18
15
|
content: Uint8Array | string
|
|
19
16
|
contentType?: string
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the mailer before it reaches the adapter.
|
|
20
|
+
* Message authored by app code. Never raw HTML: body is a React Email
|
|
21
|
+
* component, rendered to HTML + text by the mailer before the adapter.
|
|
26
22
|
*/
|
|
27
23
|
export interface MailMessage {
|
|
28
24
|
to: MailRecipient | MailRecipient[]
|
|
29
25
|
subject: string
|
|
30
|
-
/**
|
|
26
|
+
/** React Email component; rendered to HTML + text automatically. */
|
|
31
27
|
react: ReactElement
|
|
32
|
-
/** Overrides the default `from
|
|
28
|
+
/** Overrides the mailer's default `from`. */
|
|
33
29
|
from?: MailRecipient
|
|
34
30
|
replyTo?: MailRecipient
|
|
35
31
|
cc?: MailRecipient | MailRecipient[]
|
|
36
32
|
bcc?: MailRecipient | MailRecipient[]
|
|
37
33
|
headers?: Record<string, string>
|
|
38
|
-
/** Provider-agnostic
|
|
34
|
+
/** Provider-agnostic tags for analytics/filtering. */
|
|
39
35
|
tags?: Record<string, string>
|
|
40
36
|
attachments?: MailAttachment[]
|
|
41
37
|
}
|
|
@@ -46,17 +42,14 @@ export interface SentMail {
|
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
|
-
* The port the
|
|
50
|
-
*
|
|
45
|
+
* The port the app depends on. Swap providers by swapping the adapter passed
|
|
46
|
+
* to {@link createMailer}; this interface never changes.
|
|
51
47
|
*/
|
|
52
48
|
export interface Mailer {
|
|
53
49
|
send(message: MailMessage): Promise<SentMail>
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
/**
|
|
57
|
-
* The message shape an adapter receives: addresses are normalized and the body
|
|
58
|
-
* is already rendered to `html` + `text`. Adapters do no rendering.
|
|
59
|
-
*/
|
|
52
|
+
/** Shape an adapter receives: addresses normalized, body pre-rendered to `html` + `text`. */
|
|
60
53
|
export interface RenderedMessage {
|
|
61
54
|
to: MailAddress[]
|
|
62
55
|
from: MailAddress
|
|
@@ -71,13 +64,13 @@ export interface RenderedMessage {
|
|
|
71
64
|
attachments?: MailAttachment[]
|
|
72
65
|
}
|
|
73
66
|
|
|
74
|
-
/**
|
|
67
|
+
/** Contract each provider implements. */
|
|
75
68
|
export interface MailerAdapter {
|
|
76
69
|
readonly name: string
|
|
77
70
|
send(message: RenderedMessage): Promise<SentMail>
|
|
78
71
|
}
|
|
79
72
|
|
|
80
|
-
/** Normalized error
|
|
73
|
+
/** Normalized adapter error so callers never catch provider types. */
|
|
81
74
|
export class MailerError extends Error {
|
|
82
75
|
readonly adapter: string
|
|
83
76
|
|
|
@@ -6,10 +6,10 @@ export interface RenderedBody {
|
|
|
6
6
|
text: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** Turns a React Email component into HTML + text. */
|
|
10
10
|
export type EmailRenderer = (react: ReactElement) => Promise<RenderedBody>
|
|
11
11
|
|
|
12
|
-
/** Default renderer
|
|
12
|
+
/** Default renderer via `@react-email/render`. */
|
|
13
13
|
export const renderEmail: EmailRenderer = async (react) => {
|
|
14
14
|
const [html, text] = await Promise.all([render(react), render(react, { plainText: true })])
|
|
15
15
|
return { html, text }
|
|
@@ -3,21 +3,19 @@ import type { Mailer, MailerAdapter, MailRecipient, RenderedMessage } from './co
|
|
|
3
3
|
import { type EmailRenderer, renderEmail } from './core/render'
|
|
4
4
|
|
|
5
5
|
export interface CreateMailerOptions {
|
|
6
|
-
/**
|
|
6
|
+
/** Provider implementation (Resend, Brevo, …). */
|
|
7
7
|
adapter: MailerAdapter
|
|
8
|
-
/** Default sender
|
|
8
|
+
/** Default sender when a message omits `from`. */
|
|
9
9
|
from: MailRecipient
|
|
10
|
-
/** Override the
|
|
10
|
+
/** Override the renderer (mostly for tests). */
|
|
11
11
|
render?: EmailRenderer
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Build a {@link Mailer}.
|
|
16
|
-
* the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* address, applies the default sender, then hands a {@link RenderedMessage} to
|
|
20
|
-
* the adapter.
|
|
15
|
+
* Build a {@link Mailer}. Composition root: pick the adapter here; the rest of
|
|
16
|
+
* the app depends only on the `Mailer` port. Renders the React body to
|
|
17
|
+
* HTML + text, normalizes addresses, applies the default sender, then hands a
|
|
18
|
+
* {@link RenderedMessage} to the adapter.
|
|
21
19
|
*/
|
|
22
20
|
export function createMailer(options: CreateMailerOptions): Mailer {
|
|
23
21
|
const defaultFrom = normalizeAddress(options.from)
|
|
@@ -11,7 +11,7 @@ function required(value: string | undefined, name: string): string {
|
|
|
11
11
|
return value
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
// Lazy
|
|
14
|
+
// Lazy: app boots without RESEND_API_KEY; adapter built on first send.
|
|
15
15
|
let mailer: Mailer | null = null
|
|
16
16
|
function getMailer(): Mailer {
|
|
17
17
|
if (!mailer) {
|
|
@@ -13,10 +13,10 @@ import { createQueryClient } from './query-client'
|
|
|
13
13
|
let clientQueryClientSingleton: QueryClient | undefined
|
|
14
14
|
const getQueryClient = () => {
|
|
15
15
|
if (typeof window === 'undefined') {
|
|
16
|
-
// Server: always
|
|
16
|
+
// Server: always new client.
|
|
17
17
|
return createQueryClient()
|
|
18
18
|
}
|
|
19
|
-
// Browser: reuse
|
|
19
|
+
// Browser: reuse across renders.
|
|
20
20
|
clientQueryClientSingleton ??= createQueryClient()
|
|
21
21
|
return clientQueryClientSingleton
|
|
22
22
|
}
|
|
@@ -7,7 +7,7 @@ import { type AppRouter, createCaller } from '~/server/api/root'
|
|
|
7
7
|
import { createTRPCContext } from '~/server/api/trpc'
|
|
8
8
|
import { createQueryClient } from './query-client'
|
|
9
9
|
|
|
10
|
-
/** Context for tRPC calls
|
|
10
|
+
/** Context for tRPC calls from RSCs. */
|
|
11
11
|
const createContext = cache(async () => {
|
|
12
12
|
const heads = new Headers(await headers())
|
|
13
13
|
heads.set('x-trpc-source', 'rsc')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsr generate && tsc --noEmit
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files.watcherExclude": {
|
|
3
|
+
"**/routeTree.gen.ts": true
|
|
4
|
+
},
|
|
5
|
+
"search.exclude": {
|
|
6
|
+
"**/routeTree.gen.ts": true
|
|
7
|
+
},
|
|
8
|
+
"files.readonlyInclude": {
|
|
9
|
+
"**/routeTree.gen.ts": true
|
|
10
|
+
},
|
|
11
|
+
"[javascript]": {
|
|
12
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
13
|
+
},
|
|
14
|
+
"[javascriptreact]": {
|
|
15
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
16
|
+
},
|
|
17
|
+
"[typescript]": {
|
|
18
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
19
|
+
},
|
|
20
|
+
"[typescriptreact]": {
|
|
21
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
22
|
+
},
|
|
23
|
+
"[json]": {
|
|
24
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
25
|
+
},
|
|
26
|
+
"[jsonc]": {
|
|
27
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
28
|
+
},
|
|
29
|
+
"[css]": {
|
|
30
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
31
|
+
},
|
|
32
|
+
"editor.codeActionsOnSave": {
|
|
33
|
+
"source.organizeImports.biome": "explicit"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lsp": {
|
|
3
|
+
"biome": {
|
|
4
|
+
"settings": {
|
|
5
|
+
"require_config_file": true,
|
|
6
|
+
"config_path": "biome.jsonc"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
"languages": {
|
|
12
|
+
"Markdown": {
|
|
13
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
14
|
+
},
|
|
15
|
+
"JSONC": {
|
|
16
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
17
|
+
},
|
|
18
|
+
"JSON": {
|
|
19
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
20
|
+
},
|
|
21
|
+
"HTML": { "formatter": { "language_server": { "name": "biome" } } },
|
|
22
|
+
"CSS": { "formatter": { "language_server": { "name": "biome" } } },
|
|
23
|
+
"JavaScript": {
|
|
24
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
25
|
+
"code_actions_on_format": {
|
|
26
|
+
"source.fixAll.biome": true,
|
|
27
|
+
"source.organizeImports.biome": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"TypeScript": {
|
|
31
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
32
|
+
"code_actions_on_format": {
|
|
33
|
+
"source.fixAll.biome": true,
|
|
34
|
+
"source.organizeImports.biome": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"TSX": {
|
|
38
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
39
|
+
"code_actions_on_format": {
|
|
40
|
+
"source.fixAll.biome": true,
|
|
41
|
+
"source.organizeImports.biome": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -2,7 +2,7 @@ import { Loader2 } from 'lucide-react'
|
|
|
2
2
|
import type { ComponentProps } from 'react'
|
|
3
3
|
import { cn } from '~/lib/utils'
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/** Spinning lucide `loader` icon. */
|
|
6
6
|
export function Spinner({ className, ...props }: ComponentProps<typeof Loader2>) {
|
|
7
7
|
return <Loader2 aria-hidden="true" className={cn('size-4 animate-spin', className)} {...props} />
|
|
8
8
|
}
|
|
@@ -5,9 +5,9 @@ import { createEmailTheme, type EmailTheme, type EmailThemeOverride } from './th
|
|
|
5
5
|
export interface EmailLayoutProps {
|
|
6
6
|
preview: string
|
|
7
7
|
children: React.ReactNode
|
|
8
|
-
/** Per-email theme override, merged onto
|
|
8
|
+
/** Per-email theme override, merged onto default. */
|
|
9
9
|
theme?: EmailTheme | EmailThemeOverride
|
|
10
|
-
/** Footer year
|
|
10
|
+
/** Footer year; pass explicitly for deterministic output. */
|
|
11
11
|
year?: number
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -12,7 +12,7 @@ export function EmailThemeProvider({ theme, children }: EmailThemeProviderProps)
|
|
|
12
12
|
return <EmailThemeContext.Provider value={theme}>{children}</EmailThemeContext.Provider>
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** Active email theme; falls back to {@link defaultTheme}. */
|
|
16
16
|
export function useEmailTheme(): EmailTheme {
|
|
17
17
|
return useContext(EmailThemeContext)
|
|
18
18
|
}
|