@stjoseph/prumo 0.0.5 → 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/.prumo-templates/api/auth.md +51 -95
- package/.prumo-templates/api/config.md +30 -56
- package/.prumo-templates/api/errors.md +64 -44
- package/.prumo-templates/api/logging.md +27 -44
- package/.prumo-templates/api/observability.md +36 -62
- package/.prumo-templates/api/pagination.md +23 -38
- package/.prumo-templates/api/routes.md +67 -48
- package/.prumo-templates/api/structure.md +130 -0
- package/.prumo-templates/client/data.md +4 -3
- package/.prumo-templates/core/conventions.md +10 -7
- package/.prumo-templates/core/testing.md +43 -26
- package/.prumo-templates/core/tooling.md +33 -30
- package/.prumo-templates/database/entities.md +53 -61
- package/.prumo-templates/database/queries.md +34 -47
- package/.prumo-templates/database/transactions.md +28 -38
- package/.prumo-templates/mcp/server.md +54 -0
- package/.prumo-templates/monorepo/dependencies.md +3 -3
- package/.prumo-templates/monorepo/layout.md +1 -1
- package/README.md +12 -6
- package/dist/asker.js +4 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +4 -0
- package/dist/commands.js +2 -0
- package/dist/compose.js +12 -8
- package/dist/context.js +2 -0
- package/dist/generate.js +8 -0
- package/dist/mcp.js +100 -0
- package/dist/questions.js +21 -1
- package/package.json +4 -3
- package/templates/api/.swcrc +13 -0
- package/templates/api/README.md +10 -1
- package/templates/api/biome.jsonc +5 -3
- package/templates/api/package.json +22 -28
- package/templates/api/pnpm-workspace.yaml +0 -2
- package/templates/api/scripts/ports.mjs +508 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
- package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
- package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
- package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
- package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
- package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
- package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
- package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
- package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
- package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
- package/templates/api/src/infra/auth/auth.cli.ts +5 -0
- package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
- package/templates/api/src/infra/config/env.ts +47 -0
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
- package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
- package/templates/api/src/infra/di/index.ts +32 -0
- package/templates/api/src/infra/di/users.di.ts +7 -0
- package/templates/api/src/infra/http/app.ts +99 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
- package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
- package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
- package/templates/api/src/infra/http/errors/http-error.ts +8 -0
- package/templates/api/src/infra/http/fastify.d.ts +17 -0
- package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
- package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
- package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
- package/templates/api/src/infra/http/server.ts +34 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
- package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
- package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
- package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
- package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
- package/templates/api/test/infra/http/app.spec.ts +21 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
- package/templates/api/test/support/factories/profile.factory.ts +25 -0
- package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
- package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
- package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
- package/templates/api/tsconfig.json +3 -6
- package/templates/api/vitest.config.mts +4 -4
- package/templates/mobile/.env.example +3 -0
- package/templates/mobile/README.md +10 -0
- package/templates/mobile/jest.config.js +5 -1
- package/templates/mobile/package.json +3 -3
- package/templates/mobile/scripts/ports.mjs +508 -0
- package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
- package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/mobile/tsconfig.json +2 -1
- package/templates/site/.env.example +2 -0
- package/templates/site/README.md +12 -1
- package/templates/site/gitignore +1 -0
- package/templates/site/package.json +1 -1
- package/templates/site/scripts/ports.mjs +508 -0
- package/templates/site/{src → test}/app/page.spec.ts +1 -1
- package/templates/site/tsconfig.json +2 -1
- package/templates/site/vitest.config.mts +4 -2
- package/templates/web/.env.example +3 -0
- package/templates/web/README.md +10 -1
- package/templates/web/package.json +5 -3
- package/templates/web/scripts/ports.mjs +508 -0
- package/templates/web/src/features/auth/auth-client.ts +5 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
- package/templates/web/src/features/oauth/consent-form.tsx +63 -0
- package/templates/web/src/routeTree.gen.ts +21 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
- package/templates/web/{src → test}/app.spec.tsx +2 -2
- package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
- package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
- package/templates/web/tsconfig.json +2 -1
- package/templates/web/vite.config.ts +3 -3
- package/templates/workspace/README.md +7 -3
- package/templates/workspace/scripts/ports.mjs +508 -0
- package/.prumo-templates/api/modules.md +0 -117
- package/templates/api/nest-cli.json +0 -10
- package/templates/api/src/app.module.ts +0 -46
- package/templates/api/src/auth/auth.constants.ts +0 -2
- package/templates/api/src/auth/auth.controller.ts +0 -19
- package/templates/api/src/auth/auth.guard.ts +0 -43
- package/templates/api/src/auth/auth.module.ts +0 -30
- package/templates/api/src/auth/auth.ts +0 -4
- package/templates/api/src/auth/current-user.decorator.ts +0 -9
- package/templates/api/src/auth/express.d.ts +0 -10
- package/templates/api/src/auth/public.decorator.ts +0 -4
- package/templates/api/src/config/env.module.ts +0 -11
- package/templates/api/src/config/env.ts +0 -81
- package/templates/api/src/errors/problem-details.filter.ts +0 -86
- package/templates/api/src/errors/validation-exception.factory.ts +0 -30
- package/templates/api/src/health/health.controller.ts +0 -32
- package/templates/api/src/health/health.module.ts +0 -9
- package/templates/api/src/main.ts +0 -43
- package/templates/api/src/mikro-orm.factory.ts +0 -14
- package/templates/api/src/request-context/request-context.module.ts +0 -9
- package/templates/api/src/request-context/request-context.service.ts +0 -17
- package/templates/api/src/request-context/request-id.middleware.ts +0 -19
- package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
- package/templates/api/src/users/entities/profile.entity.ts +0 -27
- package/templates/api/src/users/users.controller.ts +0 -23
- package/templates/api/src/users/users.module.ts +0 -10
- package/templates/api/src/users/users.service.spec.ts +0 -44
- package/templates/api/src/users/users.service.ts +0 -36
- package/templates/api/test/factories/profile.factory.ts +0 -26
- package/templates/api/tsconfig.build.json +0 -8
- /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
- /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
|
@@ -13,6 +13,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
|
|
13
13
|
import { Route as SignInRouteImport } from './routes/sign-in'
|
|
14
14
|
import { Route as SignUpRouteImport } from './routes/sign-up'
|
|
15
15
|
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
|
16
|
+
import { Route as AuthenticatedConsentRouteImport } from './routes/_authenticated/consent'
|
|
16
17
|
|
|
17
18
|
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
|
18
19
|
id: '/_authenticated',
|
|
@@ -33,15 +34,22 @@ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
|
|
|
33
34
|
path: '/',
|
|
34
35
|
getParentRoute: () => AuthenticatedRoute,
|
|
35
36
|
} as any)
|
|
37
|
+
const AuthenticatedConsentRoute = AuthenticatedConsentRouteImport.update({
|
|
38
|
+
id: '/consent',
|
|
39
|
+
path: '/consent',
|
|
40
|
+
getParentRoute: () => AuthenticatedRoute,
|
|
41
|
+
} as any)
|
|
36
42
|
|
|
37
43
|
export interface FileRoutesByFullPath {
|
|
38
44
|
'/': typeof AuthenticatedIndexRoute
|
|
39
45
|
'/sign-in': typeof SignInRoute
|
|
40
46
|
'/sign-up': typeof SignUpRoute
|
|
47
|
+
'/consent': typeof AuthenticatedConsentRoute
|
|
41
48
|
}
|
|
42
49
|
export interface FileRoutesByTo {
|
|
43
50
|
'/sign-in': typeof SignInRoute
|
|
44
51
|
'/sign-up': typeof SignUpRoute
|
|
52
|
+
'/consent': typeof AuthenticatedConsentRoute
|
|
45
53
|
'/': typeof AuthenticatedIndexRoute
|
|
46
54
|
}
|
|
47
55
|
export interface FileRoutesById {
|
|
@@ -49,18 +57,20 @@ export interface FileRoutesById {
|
|
|
49
57
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
|
50
58
|
'/sign-in': typeof SignInRoute
|
|
51
59
|
'/sign-up': typeof SignUpRoute
|
|
60
|
+
'/_authenticated/consent': typeof AuthenticatedConsentRoute
|
|
52
61
|
'/_authenticated/': typeof AuthenticatedIndexRoute
|
|
53
62
|
}
|
|
54
63
|
export interface FileRouteTypes {
|
|
55
64
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
56
|
-
fullPaths: '/' | '/sign-in' | '/sign-up'
|
|
65
|
+
fullPaths: '/' | '/sign-in' | '/sign-up' | '/consent'
|
|
57
66
|
fileRoutesByTo: FileRoutesByTo
|
|
58
|
-
to: '/sign-in' | '/sign-up' | '/'
|
|
67
|
+
to: '/sign-in' | '/sign-up' | '/consent' | '/'
|
|
59
68
|
id:
|
|
60
69
|
| '__root__'
|
|
61
70
|
| '/_authenticated'
|
|
62
71
|
| '/sign-in'
|
|
63
72
|
| '/sign-up'
|
|
73
|
+
| '/_authenticated/consent'
|
|
64
74
|
| '/_authenticated/'
|
|
65
75
|
fileRoutesById: FileRoutesById
|
|
66
76
|
}
|
|
@@ -100,14 +110,23 @@ declare module '@tanstack/react-router' {
|
|
|
100
110
|
preLoaderRoute: typeof AuthenticatedIndexRouteImport
|
|
101
111
|
parentRoute: typeof AuthenticatedRoute
|
|
102
112
|
}
|
|
113
|
+
'/_authenticated/consent': {
|
|
114
|
+
id: '/_authenticated/consent'
|
|
115
|
+
path: '/consent'
|
|
116
|
+
fullPath: '/consent'
|
|
117
|
+
preLoaderRoute: typeof AuthenticatedConsentRouteImport
|
|
118
|
+
parentRoute: typeof AuthenticatedRoute
|
|
119
|
+
}
|
|
103
120
|
}
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
interface AuthenticatedRouteChildren {
|
|
124
|
+
AuthenticatedConsentRoute: typeof AuthenticatedConsentRoute
|
|
107
125
|
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
|
108
126
|
}
|
|
109
127
|
|
|
110
128
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
|
129
|
+
AuthenticatedConsentRoute: AuthenticatedConsentRoute,
|
|
111
130
|
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
|
112
131
|
}
|
|
113
132
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { ConsentForm } from '@/features/oauth/consent-form'
|
|
4
|
+
|
|
5
|
+
// Loose, because every parameter Better Auth signed has to stay in the URL for the consent request.
|
|
6
|
+
export const Route = createFileRoute('/_authenticated/consent')({
|
|
7
|
+
validateSearch: z.looseObject({ client_id: z.string(), scope: z.string().default('') }),
|
|
8
|
+
component: ConsentPage,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
function ConsentPage() {
|
|
12
|
+
const { auth } = Route.useRouteContext()
|
|
13
|
+
const { client_id, scope } = Route.useSearch()
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<h1 className="text-2xl font-semibold">Allow access</h1>
|
|
18
|
+
<ConsentForm auth={auth} clientId={client_id} scopes={scope.split(' ').filter(Boolean)} />
|
|
19
|
+
</>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { fakeTransport, json } from '@test/support/fake-transport'
|
|
2
|
+
import { renderApp } from '@test/support/render-app'
|
|
1
3
|
import { screen } from '@testing-library/react'
|
|
2
4
|
import userEvent from '@testing-library/user-event'
|
|
3
5
|
import { describe, expect, it } from 'vitest'
|
|
4
6
|
import type { Profile } from '@/api-contract'
|
|
5
|
-
import { fakeTransport, json } from '../test/fake-transport'
|
|
6
|
-
import { renderApp } from '../test/render-app'
|
|
7
7
|
|
|
8
8
|
const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
|
|
9
9
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { fakeTransport, json } from '@test/support/fake-transport'
|
|
2
|
+
import { renderApp } from '@test/support/render-app'
|
|
1
3
|
import { screen, within } from '@testing-library/react'
|
|
2
4
|
import userEvent from '@testing-library/user-event'
|
|
3
5
|
import { describe, expect, it } from 'vitest'
|
|
4
|
-
import { fakeTransport, json } from '../../../test/fake-transport'
|
|
5
|
-
import { renderApp } from '../../../test/render-app'
|
|
6
6
|
|
|
7
7
|
describe('SignInForm', () => {
|
|
8
8
|
it('shows a rejected sign-in as a form-level error', async () => {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { fakeTransport, json } from '@test/support/fake-transport'
|
|
2
|
+
import { renderApp } from '@test/support/render-app'
|
|
3
|
+
import { screen } from '@testing-library/react'
|
|
4
|
+
import userEvent from '@testing-library/user-event'
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
6
|
+
|
|
7
|
+
const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
|
|
8
|
+
|
|
9
|
+
// The shape Better Auth signs: every signed name listed in ba_param, then the signature.
|
|
10
|
+
const SIGNED =
|
|
11
|
+
'client_id=client-1&scope=openid+profile&ba_param=ba_param&ba_param=client_id&ba_param=scope&sig=signature'
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
window.history.replaceState(null, '', '/')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('ConsentForm', () => {
|
|
18
|
+
it('names the client, lists the scopes and sends the signed query with the answer', async () => {
|
|
19
|
+
const visitor = userEvent.setup()
|
|
20
|
+
let consent: Record<string, unknown> | undefined
|
|
21
|
+
|
|
22
|
+
// The OAuth client plugin reads the browser's own URL, not the router's memory history.
|
|
23
|
+
window.history.replaceState(null, '', `/consent?${SIGNED}&unrelated=1`)
|
|
24
|
+
|
|
25
|
+
renderApp(
|
|
26
|
+
`/consent?${SIGNED}`,
|
|
27
|
+
fakeTransport({
|
|
28
|
+
'GET /api/auth/get-session': () =>
|
|
29
|
+
json(200, { session: { id: 's-1', userId: user.id }, user }),
|
|
30
|
+
'GET /api/auth/oauth2/public-client': () => json(200, { client_name: 'Claude' }),
|
|
31
|
+
'POST /api/auth/oauth2/consent': async (request) => {
|
|
32
|
+
consent = await request.json()
|
|
33
|
+
return json(200, { redirect: false })
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
expect(await screen.findByText('Claude')).toBeInTheDocument()
|
|
39
|
+
expect(screen.getByText('profile')).toBeInTheDocument()
|
|
40
|
+
|
|
41
|
+
await visitor.click(screen.getByRole('button', { name: 'Allow' }))
|
|
42
|
+
|
|
43
|
+
expect(consent).toMatchObject({ accept: true, oauth_query: SIGNED })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
2
3
|
import { render, screen } from '@testing-library/react'
|
|
3
4
|
import userEvent from '@testing-library/user-event'
|
|
4
5
|
import { describe, expect, it } from 'vitest'
|
|
5
6
|
import { createClient, type Profile } from '@/api-contract'
|
|
6
|
-
import {
|
|
7
|
-
import { ProfileForm } from './profile-form'
|
|
7
|
+
import { ProfileForm } from '@/features/profile/profile-form'
|
|
8
8
|
|
|
9
9
|
const profile: Profile = {
|
|
10
10
|
id: '01a09ffe-635c-77e5-9aeb-d3e9d1a7ee24',
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { QueryClient } from '@tanstack/react-query'
|
|
2
2
|
import { createMemoryHistory } from '@tanstack/react-router'
|
|
3
|
+
import { API_URL } from '@test/support/fake-transport'
|
|
3
4
|
import { render } from '@testing-library/react'
|
|
4
5
|
import { createClient, type Transport } from '@/api-contract'
|
|
5
6
|
import { App } from '@/app'
|
|
6
7
|
import { createAuth } from '@/features/auth/auth-client'
|
|
7
|
-
import { API_URL } from './fake-transport'
|
|
8
8
|
|
|
9
9
|
export function renderApp(path: string, transport: Transport) {
|
|
10
10
|
const context = {
|
|
@@ -6,13 +6,13 @@ import { defineConfig } from 'vitest/config'
|
|
|
6
6
|
export default defineConfig({
|
|
7
7
|
plugins: [tanstackRouter({ target: 'react', autoCodeSplitting: true }), react(), tailwindcss()],
|
|
8
8
|
resolve: {
|
|
9
|
-
alias: { '@': `${import.meta.dirname}/src` },
|
|
9
|
+
alias: { '@': `${import.meta.dirname}/src`, '@test': `${import.meta.dirname}/test` },
|
|
10
10
|
},
|
|
11
11
|
test: {
|
|
12
12
|
environment: 'jsdom',
|
|
13
13
|
globals: true,
|
|
14
|
-
include: ['
|
|
15
|
-
setupFiles: ['./test/setup.ts'],
|
|
14
|
+
include: ['test/**/*.spec.{ts,tsx}'],
|
|
15
|
+
setupFiles: ['./test/support/setup.ts'],
|
|
16
16
|
coverage: { provider: 'v8', include: ['src/**'] },
|
|
17
17
|
},
|
|
18
18
|
})
|
|
@@ -22,9 +22,13 @@ Node 22.17 or later and pnpm. For the API, Docker Desktop running: its database
|
|
|
22
22
|
|
|
23
23
|
## Trying it
|
|
24
24
|
|
|
25
|
-
Run `pnpm dev`.
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
Run `pnpm dev`. It first checks that every app's port is free, offering to stop whatever holds one or to move that
|
|
26
|
+
app to another port. Ctrl+C stops every app it started, and the ports it used are free again.
|
|
27
|
+
|
|
28
|
+
If the workspace holds `api` and its database is not set up yet, `pnpm dev` offers to create the database first.
|
|
29
|
+
|
|
30
|
+
If the workspace holds `web`, open `http://localhost:5173`: you are sent to sign in, and **Sign up** creates an
|
|
31
|
+
account and takes you to your profile. Each app's README says what it needs before it starts.
|
|
28
32
|
|
|
29
33
|
## Conventions
|
|
30
34
|
|