galbe 0.13.0 → 0.14.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.
@@ -0,0 +1,248 @@
1
+ import ts from 'typescript'
2
+ import type { RoutePlanEntry, ScopePlan } from './openapi.parser'
3
+
4
+ const METHODS = new Set(['get', 'put', 'patch', 'post', 'delete', 'options', 'head'])
5
+
6
+ export type RouteId = string
7
+
8
+ export const routeId = (method: string, path: string): RouteId => `${method.toUpperCase()} ${path}`
9
+
10
+ export type MergeOptions = {
11
+ /** Delete routes that exist in code but are absent from the plan. Default false (kept as `stale`). */
12
+ removeStale?: boolean
13
+ /** Existing route ids (`${METHOD} ${path}`) to leave alone even if absent from plan. */
14
+ ignore?: Set<RouteId>
15
+ /** Existing-route id -> new id. Applied before diffing so a renamed route is treated as an update. */
16
+ rename?: Map<RouteId, RouteId>
17
+ }
18
+
19
+ export type MergeResult = {
20
+ content: string
21
+ added: RouteId[]
22
+ updated: RouteId[]
23
+ removed: RouteId[]
24
+ /** Stale: in code but not in plan, neither removed nor explicitly ignored. */
25
+ stale: RouteId[]
26
+ }
27
+
28
+ type ExistingRoute = {
29
+ origId: RouteId
30
+ method: string
31
+ path: string
32
+ stmt: ts.ExpressionStatement
33
+ pathArg: ts.StringLiteral
34
+ schemaArg: ts.Identifier
35
+ jsdoc: ts.CommentRange | null
36
+ }
37
+
38
+ type ExistingFile = {
39
+ text: string
40
+ sourceFile: ts.SourceFile
41
+ routes: ExistingRoute[]
42
+ schemaImport: ts.ImportDeclaration | null
43
+ bodyOpenBracePos: number | null
44
+ bodyCloseBracePos: number | null
45
+ }
46
+
47
+ const importPathForScope = (scopeKey: string): string => {
48
+ const deepness = scopeKey.split('/').length - 1
49
+ return `${Array(deepness).fill('../').join('')}schemas${scopeKey}.schema`
50
+ }
51
+
52
+ const renderFreshRouteFile = (scope: ScopePlan): string => {
53
+ const importPath = importPathForScope(scope.scopeKey)
54
+ const rDecl = scope.routes.map(r => ` ${r.meta}\ng.${r.call}`)
55
+ return (
56
+ `import { NotImplementedError, type Galbe } from 'galbe'\n` +
57
+ `import { ${[...scope.routeSchemaImports].sort().join(', ')} } from '${importPath}'\n\n` +
58
+ `export default (g: Galbe) => {\n` +
59
+ rDecl.map(d => d.replaceAll('\n', '\n ')).join('\n\n') +
60
+ `\n}\n`
61
+ )
62
+ }
63
+
64
+ const findLeadingJsDoc = (sf: ts.SourceFile, stmt: ts.Node): ts.CommentRange | null => {
65
+ const ranges = ts.getLeadingCommentRanges(sf.text, stmt.pos) || []
66
+ let last: ts.CommentRange | null = null
67
+ for (const r of ranges) {
68
+ if (r.kind !== ts.SyntaxKind.MultiLineCommentTrivia) continue
69
+ const txt = sf.text.slice(r.pos, r.end)
70
+ if (txt.startsWith('/**')) last = r
71
+ }
72
+ return last
73
+ }
74
+
75
+ const parseExistingFile = (text: string, scope: ScopePlan): ExistingFile => {
76
+ const sf = ts.createSourceFile('route.ts', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
77
+
78
+ const expectedSuffix = `schemas${scope.scopeKey}.schema`
79
+ let schemaImport: ts.ImportDeclaration | null = null
80
+ let body: ts.Block | null = null
81
+
82
+ for (const stmt of sf.statements) {
83
+ if (ts.isImportDeclaration(stmt)) {
84
+ const spec = stmt.moduleSpecifier
85
+ if (ts.isStringLiteral(spec) && spec.text.endsWith(expectedSuffix)) schemaImport = stmt
86
+ } else if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
87
+ const expr = stmt.expression
88
+ if (ts.isArrowFunction(expr) && ts.isBlock(expr.body)) body = expr.body
89
+ else if (ts.isFunctionExpression(expr)) body = expr.body
90
+ }
91
+ }
92
+
93
+ const routes: ExistingRoute[] = []
94
+ if (body) {
95
+ for (const stmt of body.statements) {
96
+ if (!ts.isExpressionStatement(stmt)) continue
97
+ const call = stmt.expression
98
+ if (!ts.isCallExpression(call)) continue
99
+ const fn = call.expression
100
+ if (!ts.isPropertyAccessExpression(fn)) continue
101
+ if (!ts.isIdentifier(fn.expression) || fn.expression.text !== 'g') continue
102
+ if (!ts.isIdentifier(fn.name)) continue
103
+ const method = fn.name.text.toLowerCase()
104
+ if (!METHODS.has(method)) continue
105
+ const args = call.arguments
106
+ if (args.length < 2) continue
107
+ const pathArg = args[0]
108
+ const schemaArg = args[1]
109
+ if (!ts.isStringLiteral(pathArg)) continue
110
+ if (!ts.isIdentifier(schemaArg)) continue
111
+
112
+ routes.push({
113
+ origId: routeId(method, pathArg.text),
114
+ method,
115
+ path: pathArg.text,
116
+ stmt,
117
+ pathArg,
118
+ schemaArg,
119
+ jsdoc: findLeadingJsDoc(sf, stmt),
120
+ })
121
+ }
122
+ }
123
+
124
+ return {
125
+ text,
126
+ sourceFile: sf,
127
+ routes,
128
+ schemaImport,
129
+ bodyOpenBracePos: body ? body.getStart(sf) : null,
130
+ bodyCloseBracePos: body ? body.end - 1 : null,
131
+ }
132
+ }
133
+
134
+ type Edit = { pos: number; end: number; text: string }
135
+
136
+ const applyEdits = (text: string, edits: Edit[]): string => {
137
+ // Apply in reverse order of pos so earlier offsets stay valid.
138
+ const sorted = [...edits].sort((a, b) => b.pos - a.pos || b.end - a.end)
139
+ let out = text
140
+ for (const e of sorted) out = out.slice(0, e.pos) + e.text + out.slice(e.end)
141
+ return out
142
+ }
143
+
144
+ export const mergeRouteFile = (
145
+ existing: string | null,
146
+ scope: ScopePlan,
147
+ opts: MergeOptions = {}
148
+ ): MergeResult => {
149
+ const removeStale = opts.removeStale ?? false
150
+ const ignore = opts.ignore ?? new Set<RouteId>()
151
+ const rename = opts.rename ?? new Map<RouteId, RouteId>()
152
+
153
+ const planById = new Map<RouteId, RoutePlanEntry>()
154
+ for (const r of scope.routes) planById.set(routeId(r.method, r.path), r)
155
+
156
+ if (existing === null || existing.trim() === '') {
157
+ return {
158
+ content: renderFreshRouteFile(scope),
159
+ added: [...planById.keys()],
160
+ updated: [],
161
+ removed: [],
162
+ stale: [],
163
+ }
164
+ }
165
+
166
+ const file = parseExistingFile(existing, scope)
167
+ const sf = file.sourceFile
168
+
169
+ const existingByEffectiveId = new Map<RouteId, ExistingRoute>()
170
+ for (const er of file.routes) {
171
+ const effId = rename.get(er.origId) ?? er.origId
172
+ existingByEffectiveId.set(effId, er)
173
+ }
174
+
175
+ const updates: { er: ExistingRoute; entry: RoutePlanEntry }[] = []
176
+ const removals: ExistingRoute[] = []
177
+ const stale: ExistingRoute[] = []
178
+
179
+ for (const er of file.routes) {
180
+ const effId = rename.get(er.origId) ?? er.origId
181
+ const planEntry = planById.get(effId)
182
+ if (planEntry) updates.push({ er, entry: planEntry })
183
+ else if (ignore.has(er.origId)) stale.push(er)
184
+ else if (removeStale) removals.push(er)
185
+ else stale.push(er)
186
+ }
187
+
188
+ const additions: RoutePlanEntry[] = []
189
+ for (const [id, entry] of planById) {
190
+ if (!existingByEffectiveId.has(id)) additions.push(entry)
191
+ }
192
+
193
+ // Compute final imported schema names.
194
+ const finalImports = new Set<string>()
195
+ for (const { entry } of updates) finalImports.add(entry.schemaName)
196
+ for (const er of stale) finalImports.add(er.schemaArg.text)
197
+ for (const entry of additions) finalImports.add(entry.schemaName)
198
+
199
+ const edits: Edit[] = []
200
+
201
+ for (const { er, entry } of updates) {
202
+ if (er.path !== entry.path) {
203
+ edits.push({ pos: er.pathArg.getStart(sf), end: er.pathArg.end, text: JSON.stringify(entry.path) })
204
+ }
205
+ if (er.schemaArg.text !== entry.schemaName) {
206
+ edits.push({ pos: er.schemaArg.getStart(sf), end: er.schemaArg.end, text: entry.schemaName })
207
+ }
208
+ if (er.jsdoc) {
209
+ edits.push({ pos: er.jsdoc.pos, end: er.jsdoc.end, text: entry.meta })
210
+ } else if (entry.meta) {
211
+ const stmtStart = er.stmt.getStart(sf)
212
+ edits.push({ pos: stmtStart, end: stmtStart, text: `${entry.meta}\n ` })
213
+ }
214
+ }
215
+
216
+ for (const er of removals) {
217
+ edits.push({ pos: er.stmt.pos, end: er.stmt.end, text: '' })
218
+ }
219
+
220
+ if (file.schemaImport) {
221
+ const sortedImports = [...finalImports].sort()
222
+ if (sortedImports.length === 0) {
223
+ edits.push({ pos: file.schemaImport.pos, end: file.schemaImport.end, text: '' })
224
+ } else {
225
+ const importPath = importPathForScope(scope.scopeKey)
226
+ const newText = `import { ${sortedImports.join(', ')} } from '${importPath}'`
227
+ edits.push({
228
+ pos: file.schemaImport.getStart(sf),
229
+ end: file.schemaImport.end,
230
+ text: newText,
231
+ })
232
+ }
233
+ }
234
+
235
+ if (additions.length > 0 && file.bodyCloseBracePos !== null) {
236
+ const block = additions.map(a => `\n ${a.meta}\n ${`g.${a.call}`.replaceAll('\n', '\n ')}\n`).join('')
237
+ edits.push({ pos: file.bodyCloseBracePos, end: file.bodyCloseBracePos, text: block })
238
+ }
239
+
240
+ return {
241
+ content: applyEdits(existing, edits),
242
+ added: additions.map(a => routeId(a.method, a.path)),
243
+ updated: updates.map(u => routeId(u.entry.method, u.entry.path)),
244
+ removed: removals.map(r => r.origId),
245
+ stale: stale.map(s => s.origId),
246
+ }
247
+ }
248
+
@@ -2,14 +2,45 @@ import { $ } from 'bun'
2
2
  import { devNull } from 'os'
3
3
  import { Command, Option } from 'commander'
4
4
  import { resolve, relative, extname } from 'path'
5
- import { rm, exists } from 'fs/promises'
5
+ import { exists, readFile } from 'fs/promises'
6
6
 
7
7
  import { CWD, fmtList, fmtVal } from '../../util'
8
- import { generateFromOapi } from './code/openapi.parser'
8
+ import { applyPlan, planFromOapi, type GenerationPlan } from './code/openapi.parser'
9
+ import { mergeRouteFile, type MergeOptions, type RouteId } from './code/route-merge'
9
10
 
10
11
  const srcTargets = ['ts', 'js']
11
12
  const inputFormats = ['openapi:3.0:yaml', 'openapi:3.0:json']
12
13
 
14
+ const collectFlag = (value: string, prev: string[] = []) => [...prev, value]
15
+
16
+ const parseRename = (raw: string): [RouteId, RouteId] => {
17
+ const eq = raw.indexOf('=')
18
+ if (eq < 0) throw new Error(`invalid --rename value ${fmtVal(raw)} (expected "OLD=NEW")`)
19
+ const from = raw.slice(0, eq).trim()
20
+ const to = raw.slice(eq + 1).trim()
21
+ if (!from || !to) throw new Error(`invalid --rename value ${fmtVal(raw)}`)
22
+ return [from, to]
23
+ }
24
+
25
+ type ScopedDiff = { scope: string; id: RouteId }
26
+
27
+ const printDiff = (diff: {
28
+ added: ScopedDiff[]
29
+ updated: ScopedDiff[]
30
+ removed: ScopedDiff[]
31
+ stale: ScopedDiff[]
32
+ }) => {
33
+ const out = (label: string, prefix: string, items: ScopedDiff[]) => {
34
+ if (!items.length) return
35
+ console.log(`${label} (${items.length}):`)
36
+ for (const x of items) console.log(` ${prefix} ${x.id} \x1b[2m(scope: ${x.scope})\x1b[0m`)
37
+ }
38
+ out('Added', '\x1b[32m+\x1b[0m', diff.added)
39
+ out('Updated', '\x1b[33m~\x1b[0m', diff.updated)
40
+ out('Removed', '\x1b[31m-\x1b[0m', diff.removed)
41
+ out('Stale (kept in code)', '\x1b[2m·\x1b[0m', diff.stale)
42
+ }
43
+
13
44
  export default (cmd: Command) => {
14
45
  cmd
15
46
  .description('generate \x1b[1;30m\x1b[36mGalbe\x1b[0m sources')
@@ -33,9 +64,20 @@ export default (cmd: Command) => {
33
64
  .default('ts', fmtVal('ts'))
34
65
  )
35
66
  .addOption(new Option('-o, --out <dir>', 'output dir').default('src', fmtVal('src')))
36
- .addOption(new Option('-F, --force', 'force overriding output'))
67
+ .addOption(new Option('-n, --dry-run', 'show planned changes without writing'))
68
+ .addOption(new Option('--remove-stale', 'delete routes present in code but absent from spec'))
69
+ .addOption(
70
+ new Option('--rename <pair>', '"OLD=NEW" preserve handler when route id changes (repeatable)')
71
+ .argParser(collectFlag)
72
+ .default([])
73
+ )
74
+ .addOption(
75
+ new Option('--ignore-route <route>', '"METHOD /path" leave alone if absent from spec (repeatable)')
76
+ .argParser(collectFlag)
77
+ .default([])
78
+ )
37
79
  .action(async (input, props) => {
38
- let { format, target, out, force } = props
80
+ let { format, target, out, dryRun, removeStale, rename, ignoreRoute } = props
39
81
 
40
82
  let inputExt = extname(input)
41
83
  if (inputExt === '.yml') inputExt = '.yaml'
@@ -43,34 +85,79 @@ export default (cmd: Command) => {
43
85
 
44
86
  if (!format) format = `openapi:3.0:${inputExt.slice(1)}`
45
87
 
46
- if (await exists(resolve(CWD, out))) {
47
- if(!force){
48
- console.log(
49
- `error: output directory ${fmtVal(
50
- out
51
- )} already exists. If you're sure you want to override its content, please remove it before or use the ${fmtVal(
52
- '-F --force'
53
- )} option`
54
- )
55
- process.exit(1)
56
- }
57
- await rm(resolve(CWD, out), { recursive: true })
88
+ let renameMap: Map<RouteId, RouteId>
89
+ try {
90
+ renameMap = new Map<RouteId, RouteId>((rename as string[]).map(parseRename))
91
+ } catch (err) {
92
+ console.log(`error: ${(err as Error).message}`)
93
+ process.exit(1)
94
+ }
95
+ const ignoreSet = new Set<RouteId>((ignoreRoute as string[]).map(s => s.trim()))
96
+ const mergeOpts: MergeOptions = {
97
+ removeStale: !!removeStale,
98
+ rename: renameMap,
99
+ ignore: ignoreSet,
58
100
  }
59
101
 
60
- Bun.write(Bun.stdout, '💻 \x1b[1;30mGenerating \x1b[36mGalbe\x1b[0m\x1b[1;30m sources\x1b[0m')
102
+ let plan: GenerationPlan
61
103
  try {
62
104
  let match = format.match(/^([^:]*):([^:]*):(.*)$/)
63
- if (!match) throw new Error(`error: invalid format ${format}`)
105
+ if (!match) throw new Error(`invalid format ${format}`)
64
106
  let [_, kind, version, ext] = match
65
- if (kind === 'openapi') {
66
- await generateFromOapi(relative(CWD, input), resolve(CWD, out), { version, ext, target })
67
- } else throw new Error('error: unknown format')
107
+ if (kind !== 'openapi') throw new Error('unknown format')
108
+ plan = await planFromOapi(relative(CWD, input), { version, ext, target })
109
+ } catch (err) {
110
+ console.log(`error: ${(err as Error).message}`)
111
+ process.exit(1)
112
+ }
113
+
114
+ const outDir = resolve(CWD, out)
115
+
116
+ // Compute per-scope merge result.
117
+ const mergeResults: { scopeKey: string; content: string; added: RouteId[]; updated: RouteId[]; removed: RouteId[]; stale: RouteId[] }[] = []
118
+ for (const scope of plan.scopes) {
119
+ const routePath = resolve(outDir, `${scope.routeFile}.${target}`)
120
+ const existing = (await exists(routePath)) ? await readFile(routePath, 'utf-8') : null
121
+ const r = mergeRouteFile(existing, scope, mergeOpts)
122
+ mergeResults.push({ scopeKey: scope.scopeKey, ...r })
123
+ }
124
+
125
+ const diff = {
126
+ added: mergeResults.flatMap(r => r.added.map(id => ({ scope: r.scopeKey, id }))),
127
+ updated: mergeResults.flatMap(r => r.updated.map(id => ({ scope: r.scopeKey, id }))),
128
+ removed: mergeResults.flatMap(r => r.removed.map(id => ({ scope: r.scopeKey, id }))),
129
+ stale: mergeResults.flatMap(r => r.stale.map(id => ({ scope: r.scopeKey, id }))),
130
+ }
68
131
 
69
- await $`bunx prettier --write "${resolve(CWD, out)}/**/*.{js,ts}" > ${devNull} && printf "\u200B"`
132
+ // Block when stale routes need an explicit decision (ignored ones are already resolved).
133
+ const unresolved = diff.stale.filter(s => !ignoreSet.has(s.id))
134
+ if (unresolved.length > 0 && !removeStale && !dryRun) {
135
+ console.log('The following routes exist in code but are not in the spec:')
136
+ for (const s of unresolved) console.log(` - ${s.id} \x1b[2m(scope: ${s.scope})\x1b[0m`)
137
+ console.log()
138
+ console.log('Re-run with one of:')
139
+ console.log(` ${fmtVal('--rename "OLD=NEW"')} treat as a rename, preserve handler`)
140
+ console.log(` ${fmtVal('--ignore-route "ROUTE"')} leave alone, keep as user-managed`)
141
+ console.log(` ${fmtVal('--remove-stale')} confirm deletion of stale routes`)
142
+ process.exit(1)
143
+ }
144
+
145
+ if (dryRun) {
146
+ console.log('\x1b[1;30mDry run — no files will be written.\x1b[0m')
147
+ printDiff(diff)
148
+ return
149
+ }
150
+
151
+ Bun.write(Bun.stdout, '💻 \x1b[1;30mGenerating \x1b[36mGalbe\x1b[0m\x1b[1;30m sources\x1b[0m')
152
+ try {
153
+ const routeContents = new Map(mergeResults.map(r => [r.scopeKey, r.content]))
154
+ await applyPlan(plan, outDir, { routeContents })
155
+ await $`bunx prettier --write "${outDir}/**/*.{js,ts}" > ${devNull} && printf "​"`
70
156
  } catch (err) {
71
- console.log(`error: ${err.message}`)
157
+ console.log(`error: ${(err as Error).message}`)
72
158
  process.exit(1)
73
159
  }
74
160
  Bun.write(Bun.stdout, ' : \x1b[1;30m\x1b[32mdone\x1b[0m\n')
161
+ printDiff(diff)
75
162
  })
76
163
  }
@@ -4,6 +4,7 @@ import client from './client'
4
4
  import spec from './spec'
5
5
  import code from './code'
6
6
  import model from './model'
7
+ import cli from './cli/index'
7
8
 
8
9
  export default (cmd: Command) => {
9
10
  cmd.description('generate util')
@@ -11,4 +12,5 @@ export default (cmd: Command) => {
11
12
  client(cmd.command('client'))
12
13
  code(cmd.command('code'))
13
14
  model(cmd.command('model'))
15
+ cli(cmd.command('cli'))
14
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "galbe",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Fast, lightweight and highly customizable JavaScript web framework based on Bun",
5
5
  "author": "Pierre Caillaud M (https://github.com/pierre-cm)",
6
6
  "type": "module",
@@ -61,6 +61,9 @@
61
61
  "github": {
62
62
  "requireBranch": "main",
63
63
  "release": "true"
64
+ },
65
+ "npm": {
66
+ "publish": false
64
67
  }
65
68
  }
66
69
  }
package/src/cookies.ts ADDED
@@ -0,0 +1,87 @@
1
+ export type CookieOptions = {
2
+ path?: string
3
+ maxAge?: number
4
+ httpOnly?: boolean
5
+ sameSite?: true | false | 'lax' | 'strict' | 'none'
6
+ secure?: boolean
7
+ domain?: string
8
+ expires?: Date
9
+ }
10
+ export const parseCookie = (str: string) => {
11
+ type Cookie = {
12
+ name: string
13
+ value: string
14
+ } & CookieOptions
15
+ let cookie: Cookie = { name: '', value: '', path: '/' }
16
+ const entries = str.match(/([^=;\s]+)(?:=([^;]*))?/g)
17
+ if (entries) {
18
+ for (const [idx, entry] of entries.entries()) {
19
+ const [_, key, val] = [...(entry.match(/([^=]+)(?:=(.*))?/) || [])]
20
+ if (idx === 0) {
21
+ cookie.name = key
22
+ cookie.value = val
23
+ } else {
24
+ switch (key) {
25
+ case 'Path':
26
+ cookie.path = val
27
+ break
28
+ case 'Max-Age':
29
+ cookie.maxAge = parseInt(val)
30
+ break
31
+ case 'HttpOnly':
32
+ cookie.httpOnly = true
33
+ break
34
+ case 'SameSite':
35
+ cookie.sameSite =
36
+ ({ true: true, false: false, lax: 'lax', strict: 'strict', none: 'none' } as const)[
37
+ val?.toLowerCase() || 'true'
38
+ ] || true
39
+ break
40
+ case 'Secure':
41
+ cookie.secure = true
42
+ break
43
+ case 'Domain':
44
+ cookie.domain = val
45
+ break
46
+ case 'Expires':
47
+ cookie.expires = new Date(val)
48
+ break
49
+ }
50
+ }
51
+ }
52
+ }
53
+ return cookie
54
+ }
55
+ export const stringifyCookie = (name: string, value: string, opt: CookieOptions = { path: '/' }) => {
56
+ const sameSite =
57
+ typeof opt.sameSite === 'string'
58
+ ? ` SameSite=${capitalize(opt.sameSite)};`
59
+ : opt.sameSite === true
60
+ ? ' SameSite=Lax;'
61
+ : ''
62
+ return (
63
+ `${name}=${value};` +
64
+ ` path=${opt.path || '/'};` +
65
+ (opt.domain ? ` Domain=${opt.domain};` : '') +
66
+ (typeof opt.maxAge === 'number' ? ` Max-Age=${Math.floor(opt.maxAge)};` : '') +
67
+ (opt.expires ? ` Expires=${opt.expires.toUTCString()};` : '') +
68
+ (opt.secure ? ' Secure;' : '') +
69
+ sameSite +
70
+ (opt.httpOnly ? ' HttpOnly;' : '')
71
+ )
72
+ }
73
+
74
+ function capitalize(str?: string) {
75
+ if (!str) return ''
76
+ return str.charAt(0).toUpperCase() + str.slice(1)
77
+ }
78
+
79
+ export const readCookies = (cookies?: string | null) => {
80
+ if (!cookies) return {}
81
+ return Object.fromEntries(
82
+ cookies.split(';').map(c => {
83
+ const [name, ...value] = c.split('=')
84
+ return [name.trim(), value.join('=').trim()]
85
+ })
86
+ )
87
+ }