@ahmedbaset/adminjs-hono 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.
Files changed (59) hide show
  1. package/.eslintrc.cjs +20 -0
  2. package/README.md +239 -0
  3. package/examples/auth.ts +76 -0
  4. package/examples/simple.ts +42 -0
  5. package/lib/authentication/login.handler.d.ts +11 -0
  6. package/lib/authentication/login.handler.d.ts.map +1 -0
  7. package/lib/authentication/login.handler.js +155 -0
  8. package/lib/authentication/logout.handler.d.ts +11 -0
  9. package/lib/authentication/logout.handler.d.ts.map +1 -0
  10. package/lib/authentication/logout.handler.js +50 -0
  11. package/lib/authentication/protected-routes.handler.d.ts +11 -0
  12. package/lib/authentication/protected-routes.handler.d.ts.map +1 -0
  13. package/lib/authentication/protected-routes.handler.js +26 -0
  14. package/lib/authentication/refresh.handler.d.ts +13 -0
  15. package/lib/authentication/refresh.handler.d.ts.map +1 -0
  16. package/lib/authentication/refresh.handler.js +42 -0
  17. package/lib/buildAuthenticatedRouter.d.ts +15 -0
  18. package/lib/buildAuthenticatedRouter.d.ts.map +1 -0
  19. package/lib/buildAuthenticatedRouter.js +61 -0
  20. package/lib/buildRouter.d.ts +53 -0
  21. package/lib/buildRouter.d.ts.map +1 -0
  22. package/lib/buildRouter.js +178 -0
  23. package/lib/convertRoutes.d.ts +9 -0
  24. package/lib/convertRoutes.d.ts.map +1 -0
  25. package/lib/convertRoutes.js +10 -0
  26. package/lib/errors.d.ts +10 -0
  27. package/lib/errors.d.ts.map +1 -0
  28. package/lib/errors.js +15 -0
  29. package/lib/formParser.d.ts +13 -0
  30. package/lib/formParser.d.ts.map +1 -0
  31. package/lib/formParser.js +53 -0
  32. package/lib/index.d.ts +55 -0
  33. package/lib/index.d.ts.map +1 -0
  34. package/lib/index.js +48 -0
  35. package/lib/logger.d.ts +7 -0
  36. package/lib/logger.d.ts.map +1 -0
  37. package/lib/logger.js +17 -0
  38. package/lib/session.d.ts +25 -0
  39. package/lib/session.d.ts.map +1 -0
  40. package/lib/session.js +56 -0
  41. package/lib/types.d.ts +46 -0
  42. package/lib/types.d.ts.map +1 -0
  43. package/lib/types.js +1 -0
  44. package/package.json +44 -0
  45. package/src/authentication/login.handler.ts +193 -0
  46. package/src/authentication/logout.handler.ts +62 -0
  47. package/src/authentication/protected-routes.handler.ts +38 -0
  48. package/src/authentication/refresh.handler.ts +59 -0
  49. package/src/buildAuthenticatedRouter.ts +92 -0
  50. package/src/buildRouter.ts +224 -0
  51. package/src/convertRoutes.ts +10 -0
  52. package/src/errors.ts +24 -0
  53. package/src/formParser.ts +73 -0
  54. package/src/index.ts +74 -0
  55. package/src/logger.ts +18 -0
  56. package/src/session.ts +71 -0
  57. package/src/types.ts +53 -0
  58. package/tsconfig.json +21 -0
  59. package/vitest.config.ts +12 -0
@@ -0,0 +1,224 @@
1
+ import AdminJS, { Router as AdminRouter } from 'adminjs'
2
+ import type { Hono, Handler, Context } from 'hono'
3
+ import { WrongArgumentError, INVALID_ADMINJS_INSTANCE } from './errors.js'
4
+ import { log } from './logger.js'
5
+ import { convertToHonoRoute } from './convertRoutes.js'
6
+ import type { HonoVariables } from './types.js'
7
+
8
+ /**
9
+ * Validates and initializes an AdminJS instance
10
+ * @param admin - The AdminJS instance to initialize
11
+ * @throws {WrongArgumentError} If the admin parameter is not a valid AdminJS instance
12
+ */
13
+ export function initializeAdmin(admin: AdminJS): void {
14
+ if (admin?.constructor?.name !== 'AdminJS') {
15
+ throw new WrongArgumentError(INVALID_ADMINJS_INSTANCE)
16
+ }
17
+
18
+ admin.initialize().then(() => {
19
+ log.debug('AdminJS: bundle ready')
20
+ })
21
+ }
22
+
23
+ /**
24
+ * Creates a Hono route handler from an AdminJS route
25
+ * Extracts request data and invokes the AdminJS controller
26
+ *
27
+ * @param admin - The AdminJS instance
28
+ * @param route - The AdminJS route definition
29
+ * @returns Hono handler function
30
+ */
31
+ export function routeHandler(
32
+ admin: AdminJS,
33
+ route: (typeof AdminRouter)['routes'][0]
34
+ ): Handler<{ Variables: HonoVariables }> {
35
+ return async (c: Context<{ Variables: HonoVariables }>) => {
36
+ // Get session from context (may be undefined for non-authenticated routes)
37
+ const session = c.get('session')
38
+ const adminUser = session?.adminUser
39
+
40
+ // Instantiate the AdminJS controller
41
+ const controller = new route.Controller({ admin }, adminUser)
42
+
43
+ // Extract request data
44
+ const params = c.req.param()
45
+ const query = c.req.query()
46
+ const method = c.req.method.toLowerCase()
47
+
48
+ // Get parsed form data from context (set by form parser middleware)
49
+ const fields = c.get('fields') || {}
50
+ const files = c.get('files') || {}
51
+ const payload = {
52
+ ...fields,
53
+ ...files,
54
+ }
55
+
56
+ // Execute the controller action
57
+ const html = await controller[route.action](
58
+ {
59
+ ...c.req,
60
+ params,
61
+ query,
62
+ payload,
63
+ method,
64
+ },
65
+ c.res
66
+ )
67
+
68
+ // Set Content-Type header if specified
69
+ if (route.contentType) {
70
+ c.header('Content-Type', route.contentType)
71
+ }
72
+
73
+ // Return response
74
+ if (html) {
75
+ return c.body(html)
76
+ }
77
+
78
+ // If no response body, return empty response
79
+ return c.body(null)
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Registers a single AdminJS route with the Hono app
85
+ * @param route - The AdminJS route to register
86
+ * @param app - The Hono app instance
87
+ * @param admin - The AdminJS instance
88
+ */
89
+ export function buildRoute(
90
+ route: (typeof AdminRouter)['routes'][number],
91
+ app: Hono,
92
+ admin: AdminJS
93
+ ): void {
94
+ // Convert AdminJS route path to Hono format
95
+ const honoPath = convertToHonoRoute(route.path)
96
+
97
+ // Register handler based on HTTP method
98
+ if (route.method === 'GET') {
99
+ app.get(honoPath, routeHandler(admin, route))
100
+ } else if (route.method === 'POST') {
101
+ app.post(honoPath, routeHandler(admin, route))
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Registers all AdminJS routes with the Hono app
107
+ * @param routes - Array of AdminJS routes
108
+ * @param app - The Hono app instance
109
+ * @param admin - The AdminJS instance
110
+ */
111
+ export function buildRoutes(
112
+ routes: (typeof AdminRouter)['routes'],
113
+ app: Hono,
114
+ admin: AdminJS
115
+ ): void {
116
+ routes.forEach((route) => buildRoute(route, app, admin))
117
+ }
118
+
119
+ /**
120
+ * Determines Content-Type header based on file extension
121
+ * @param path - File path
122
+ * @returns Content-Type string
123
+ */
124
+ function getContentType(path: string): string {
125
+ if (path.endsWith('.js')) {
126
+ return 'application/javascript'
127
+ } else if (path.endsWith('.css')) {
128
+ return 'text/css'
129
+ } else if (path.endsWith('.html')) {
130
+ return 'text/html'
131
+ } else if (path.endsWith('.json')) {
132
+ return 'application/json'
133
+ } else if (path.endsWith('.png')) {
134
+ return 'image/png'
135
+ } else if (path.endsWith('.jpg') || path.endsWith('.jpeg')) {
136
+ return 'image/jpeg'
137
+ } else if (path.endsWith('.svg')) {
138
+ return 'image/svg+xml'
139
+ }
140
+ return 'application/octet-stream'
141
+ }
142
+
143
+ /**
144
+ * Registers AdminJS asset routes with the Hono app
145
+ * @param assets - Array of AdminJS assets
146
+ * @param routes - Array of AdminJS routes (to find component bundler route)
147
+ * @param app - The Hono app instance
148
+ * @param admin - The AdminJS instance
149
+ */
150
+ export function buildAssets(
151
+ assets: (typeof AdminRouter)['assets'],
152
+ routes: (typeof AdminRouter)['routes'],
153
+ app: Hono,
154
+ admin: AdminJS
155
+ ): void {
156
+ // Register component bundler route if it exists
157
+ const componentBundlerRoute = routes.find(
158
+ (r) => r.action === 'bundleComponents'
159
+ )
160
+ if (componentBundlerRoute) {
161
+ buildRoute(componentBundlerRoute, app, admin)
162
+ }
163
+
164
+ // Register static asset routes
165
+ assets.forEach((asset) => {
166
+ app.get(asset.path, async (c) => {
167
+ try {
168
+ // Read file using Node.js fs (for Node runtime)
169
+ // For other runtimes, this would need runtime detection
170
+ const fs = await import('fs/promises')
171
+ const path = await import('path')
172
+
173
+ const filePath = path.resolve(asset.src)
174
+ const fileContent = await fs.readFile(filePath)
175
+
176
+ // Set appropriate Content-Type header
177
+ const contentType = getContentType(asset.path)
178
+ c.header('Content-Type', contentType)
179
+
180
+ return c.body(fileContent)
181
+ } catch (error) {
182
+ console.error(`Error serving asset ${asset.path}:`, error)
183
+ return c.text('Asset not found', 404)
184
+ }
185
+ })
186
+ })
187
+ }
188
+
189
+ import type { UploadOptions } from './types.js'
190
+ import { createFormParserMiddleware } from './formParser.js'
191
+
192
+ /**
193
+ * Builds a Hono app with AdminJS routes (without authentication)
194
+ *
195
+ * @param admin - The AdminJS instance
196
+ * @param predefinedApp - Optional existing Hono app to use
197
+ * @param uploadOptions - Optional upload configuration
198
+ * @returns Configured Hono app
199
+ */
200
+ export function buildRouter(
201
+ admin: AdminJS,
202
+ predefinedApp?: Hono,
203
+ uploadOptions?: UploadOptions
204
+ ): Hono {
205
+ // Initialize AdminJS
206
+ initializeAdmin(admin)
207
+
208
+ // Use provided app or create new Hono instance
209
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
210
+ const { Hono: HonoClass } = require('hono')
211
+ const app = predefinedApp ?? new HonoClass()
212
+
213
+ // Get routes and assets from AdminJS
214
+ const { routes, assets } = AdminRouter
215
+
216
+ // Register form parsing middleware
217
+ app.use('*', createFormParserMiddleware(uploadOptions))
218
+
219
+ // Build assets and routes
220
+ buildAssets(assets, routes, app, admin)
221
+ buildRoutes(routes, app, admin)
222
+
223
+ return app
224
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Converts AdminJS route path format to Hono route path format
3
+ * AdminJS uses {param} notation while Hono uses :param notation
4
+ *
5
+ * @param path - AdminJS route path (e.g., "/resources/{resourceId}/actions/{action}")
6
+ * @returns Hono route path (e.g., "/resources/:resourceId/actions/:action")
7
+ */
8
+ export function convertToHonoRoute(path: string): string {
9
+ return path.replace(/\{([^}]+)\}/g, ':$1')
10
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,24 @@
1
+ export class WrongArgumentError extends Error {
2
+ constructor(message: string) {
3
+ super(message)
4
+ this.name = 'WrongArgumentError'
5
+ }
6
+ }
7
+
8
+ export const INVALID_ADMINJS_INSTANCE =
9
+ 'You have to pass an instance of AdminJS to the buildRouter() function'
10
+
11
+ export const MISSING_AUTH_CONFIG_ERROR =
12
+ 'You must provide either "authenticate" function or "provider" in authentication options'
13
+
14
+ export const INVALID_AUTH_CONFIG_ERROR =
15
+ 'You cannot provide both "authenticate" function and "provider" in authentication options'
16
+
17
+ export class OldBodyParserUsedError extends Error {
18
+ constructor() {
19
+ super(
20
+ 'You are using old body-parser middleware which is not compatible with AdminJS. Please remove it.'
21
+ )
22
+ this.name = 'OldBodyParserUsedError'
23
+ }
24
+ }
@@ -0,0 +1,73 @@
1
+ import type { MiddlewareHandler, Context } from 'hono'
2
+ import type { UploadOptions, HonoVariables } from './types.js'
3
+
4
+ /**
5
+ * Creates middleware to parse form data (multipart/form-data and application/x-www-form-urlencoded)
6
+ * Extracts fields and files from the request and stores them in context variables
7
+ *
8
+ * @param options - Upload configuration options
9
+ * @returns Hono middleware handler
10
+ */
11
+ export function createFormParserMiddleware(
12
+ options?: UploadOptions
13
+ ): MiddlewareHandler<{ Variables: HonoVariables }> {
14
+ return async (c: Context<{ Variables: HonoVariables }>, next) => {
15
+ const contentType = c.req.header('content-type')
16
+
17
+ if (
18
+ contentType?.includes('multipart/form-data') ||
19
+ contentType?.includes('application/x-www-form-urlencoded')
20
+ ) {
21
+ try {
22
+ const formData = await c.req.formData()
23
+ const fields: Record<string, any> = {}
24
+ const files: Record<string, File> = {}
25
+
26
+ for (const [key, value] of formData.entries()) {
27
+ if (value instanceof File) {
28
+ // Check file size if maxFileSize is specified
29
+ if (options?.maxFileSize && value.size > options.maxFileSize) {
30
+ throw new Error(
31
+ `File ${value.name} exceeds maximum size of ${options.maxFileSize} bytes`
32
+ )
33
+ }
34
+ files[key] = value
35
+ } else {
36
+ fields[key] = value
37
+ }
38
+ }
39
+
40
+ // Check total fields size if maxFieldsSize is specified
41
+ if (options?.maxFieldsSize) {
42
+ const totalFieldsSize = Object.values(fields).reduce(
43
+ (sum, val) => sum + String(val).length,
44
+ 0
45
+ )
46
+ if (totalFieldsSize > options.maxFieldsSize) {
47
+ throw new Error(
48
+ `Total fields size exceeds maximum of ${options.maxFieldsSize} bytes`
49
+ )
50
+ }
51
+ }
52
+
53
+ // Check number of fields if maxFields is specified
54
+ if (options?.maxFields && Object.keys(fields).length > options.maxFields) {
55
+ throw new Error(
56
+ `Number of fields exceeds maximum of ${options.maxFields}`
57
+ )
58
+ }
59
+
60
+ c.set('fields', fields)
61
+ c.set('files', files)
62
+ } catch (error) {
63
+ // Handle parsing errors gracefully
64
+ console.error('Form parsing error:', error)
65
+ c.set('fields', {})
66
+ c.set('files', {})
67
+ throw error
68
+ }
69
+ }
70
+
71
+ await next()
72
+ }
73
+ }
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { buildAuthenticatedRouter } from './buildAuthenticatedRouter.js'
2
+ import { buildRouter } from './buildRouter.js'
3
+
4
+ /**
5
+ * @module @ahmedbaset/adminjs-hono
6
+ *
7
+ * AdminJS adapter for Hono web framework
8
+ *
9
+ * Provides two main functions:
10
+ * - buildRouter: Creates a Hono app with AdminJS routes (no authentication)
11
+ * - buildAuthenticatedRouter: Creates a Hono app with AdminJS routes protected by session authentication
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { Hono } from 'hono'
16
+ * import AdminJS from 'adminjs'
17
+ * import { buildRouter } from '@ahmedbaset/adminjs-hono'
18
+ *
19
+ * const admin = new AdminJS({
20
+ * databases: [],
21
+ * rootPath: '/admin',
22
+ * })
23
+ *
24
+ * const app = new Hono()
25
+ * const adminRouter = buildRouter(admin)
26
+ * app.route('/admin', adminRouter)
27
+ * ```
28
+ */
29
+
30
+ /**
31
+ * Plugin name
32
+ */
33
+ export const name = 'AdminJSHono'
34
+
35
+ /**
36
+ * Plugin interface
37
+ */
38
+ export type HonoPlugin = {
39
+ name: string
40
+ buildAuthenticatedRouter: typeof buildAuthenticatedRouter
41
+ buildRouter: typeof buildRouter
42
+ }
43
+
44
+ /**
45
+ * Default plugin export
46
+ */
47
+ const plugin: HonoPlugin = { name, buildAuthenticatedRouter, buildRouter }
48
+
49
+ export default plugin
50
+
51
+ // Export main functions
52
+ export { buildRouter } from './buildRouter.js'
53
+ export { buildAuthenticatedRouter } from './buildAuthenticatedRouter.js'
54
+
55
+ // Export types
56
+ export type {
57
+ AuthenticationOptions,
58
+ UploadOptions,
59
+ AuthenticationMaxRetriesOptions,
60
+ AuthenticationContext,
61
+ SessionOptions,
62
+ SessionData,
63
+ } from './types.js'
64
+
65
+ // Export utilities
66
+ export { convertToHonoRoute } from './convertRoutes.js'
67
+ export * from './errors.js'
68
+ export { log } from './logger.js'
69
+
70
+ // Export authentication handlers (for advanced usage)
71
+ export { withLogin } from './authentication/login.handler.js'
72
+ export { withLogout } from './authentication/logout.handler.js'
73
+ export { withProtectedRoutesHandler } from './authentication/protected-routes.handler.js'
74
+ export { withRefresh } from './authentication/refresh.handler.js'
package/src/logger.ts ADDED
@@ -0,0 +1,18 @@
1
+ const DEBUG = process.env.ADMINJS_HONO_DEBUG === 'true'
2
+
3
+ export const log = {
4
+ debug: (...args: unknown[]) => {
5
+ if (DEBUG) {
6
+ console.log('[AdminJS Hono]', ...args)
7
+ }
8
+ },
9
+ info: (...args: unknown[]) => {
10
+ console.log('[AdminJS Hono]', ...args)
11
+ },
12
+ warn: (...args: unknown[]) => {
13
+ console.warn('[AdminJS Hono]', ...args)
14
+ },
15
+ error: (...args: unknown[]) => {
16
+ console.error('[AdminJS Hono]', ...args)
17
+ },
18
+ }
package/src/session.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { getCookie, setCookie } from 'hono/cookie'
2
+ import type { MiddlewareHandler, Context } from 'hono'
3
+ import type { SessionData, SessionOptions, HonoVariables } from './types.js'
4
+
5
+ // In-memory session store (not suitable for production with multiple instances)
6
+ const sessions = new Map<string, SessionData>()
7
+
8
+ /**
9
+ * Generates a unique session ID using Web Crypto API
10
+ * @returns A unique session identifier
11
+ */
12
+ function generateSessionId(): string {
13
+ return crypto.randomUUID()
14
+ }
15
+
16
+ /**
17
+ * Creates session middleware for Hono
18
+ * Manages cookie-based sessions with in-memory storage
19
+ *
20
+ * @param secret - Secret key for session (currently unused, for future HMAC signing)
21
+ * @param cookieName - Name of the session cookie
22
+ * @param options - Session cookie options
23
+ * @returns Hono middleware handler
24
+ */
25
+ export function createSessionMiddleware(
26
+ secret: string,
27
+ cookieName: string,
28
+ options?: SessionOptions
29
+ ): MiddlewareHandler<{ Variables: HonoVariables }> {
30
+ return async (c: Context<{ Variables: HonoVariables }>, next) => {
31
+ let sessionId = getCookie(c, cookieName)
32
+
33
+ // Create new session if none exists or session not found
34
+ if (!sessionId || !sessions.has(sessionId)) {
35
+ sessionId = generateSessionId()
36
+ sessions.set(sessionId, {})
37
+ }
38
+
39
+ // Get session data and store in context
40
+ const session = sessions.get(sessionId)!
41
+ c.set('session', session)
42
+
43
+ await next()
44
+
45
+ // Save session cookie after request processing
46
+ setCookie(c, cookieName, sessionId, {
47
+ httpOnly: options?.httpOnly ?? true,
48
+ secure: options?.secure ?? false,
49
+ sameSite: options?.sameSite ?? 'Lax',
50
+ maxAge: options?.maxAge ?? 86400, // 24 hours default
51
+ path: options?.path ?? '/',
52
+ domain: options?.domain,
53
+ })
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Destroys a session by removing it from the store
59
+ * @param sessionId - The session ID to destroy
60
+ */
61
+ export function destroySession(sessionId: string): void {
62
+ sessions.delete(sessionId)
63
+ }
64
+
65
+ /**
66
+ * Gets the session store (for testing purposes)
67
+ * @returns The session store Map
68
+ */
69
+ export function getSessionStore(): Map<string, SessionData> {
70
+ return sessions
71
+ }
package/src/types.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { BaseAuthProvider, CurrentAdmin } from 'adminjs'
2
+ import type { Context } from 'hono'
3
+
4
+ export type UploadOptions = {
5
+ uploadDir?: string
6
+ maxFileSize?: number
7
+ maxFieldsSize?: number
8
+ maxFields?: number
9
+ keepExtensions?: boolean
10
+ }
11
+
12
+ // Hono context variables
13
+ export type HonoVariables = {
14
+ session: SessionData
15
+ fields: Record<string, any>
16
+ files: Record<string, File>
17
+ }
18
+
19
+ export type AuthenticationContext = {
20
+ req: Context<{ Variables: HonoVariables }>
21
+ res: Context<{ Variables: HonoVariables }>
22
+ }
23
+
24
+ export type AuthenticationMaxRetriesOptions = {
25
+ count: number
26
+ duration: number // in seconds
27
+ }
28
+
29
+ export type AuthenticationOptions = {
30
+ cookiePassword: string
31
+ cookieName?: string
32
+ authenticate?: (
33
+ email: string,
34
+ password: string,
35
+ context?: AuthenticationContext
36
+ ) => Promise<CurrentAdmin | null> | CurrentAdmin | null
37
+ maxRetries?: number | AuthenticationMaxRetriesOptions
38
+ provider?: BaseAuthProvider
39
+ }
40
+
41
+ export type SessionOptions = {
42
+ maxAge?: number
43
+ httpOnly?: boolean
44
+ secure?: boolean
45
+ sameSite?: 'Strict' | 'Lax' | 'None'
46
+ domain?: string
47
+ path?: string
48
+ }
49
+
50
+ export type SessionData = {
51
+ adminUser?: CurrentAdmin
52
+ redirectTo?: string
53
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "bundler",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "outDir": "./lib",
10
+ "rootDir": "./src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "allowSyntheticDefaultImports": true,
17
+ "types": ["node"]
18
+ },
19
+ "include": ["src/**/*"],
20
+ "exclude": ["node_modules", "lib", "test", "examples"]
21
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ reporter: ['text', 'json', 'html'],
10
+ },
11
+ },
12
+ })