@nuasite/cli 0.17.1 → 0.18.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.
@@ -1,2 +1,23 @@
1
+ export interface CommandOptions {
2
+ cwd?: string;
3
+ dryRun?: boolean;
4
+ yes?: boolean;
5
+ }
1
6
  export declare function findAstroConfig(cwd?: string): string | null;
7
+ /**
8
+ * Find the matching closing brace/bracket/paren, aware of string literals.
9
+ */
10
+ export declare function findMatchingClose(text: string, start: number): number;
11
+ /**
12
+ * Extract the text between the outermost { } of defineConfig({ ... })
13
+ */
14
+ export declare function extractConfigBody(content: string): string;
15
+ /**
16
+ * Remove a top-level property from an object literal body text.
17
+ */
18
+ export declare function removeProperty(body: string, propName: string): string;
19
+ /**
20
+ * Assemble a complete config file from import lines and a body string.
21
+ */
22
+ export declare function assembleConfig(imports: string[], body: string): string;
2
23
  //# sourceMappingURL=utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAUA,wBAAgB,eAAe,CAAC,GAAG,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAM1E"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,cAAc;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,GAAG,CAAC,EAAE,OAAO,CAAA;CACb;AASD,wBAAgB,eAAe,CAAC,GAAG,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAM1E;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CA+BrE;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CASzD;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAgCrE;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAWtE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuasite/cli",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "files": [
@@ -23,7 +23,7 @@
23
23
  "prepack": "bun run ../../scripts/workspace-deps/resolve-deps.ts"
24
24
  },
25
25
  "dependencies": {
26
- "@nuasite/agent-summary": "0.17.1",
26
+ "@nuasite/agent-summary": "0.18.0",
27
27
  "astro": "^6.0.2",
28
28
  "stacktracey": "2.1.8"
29
29
  },
package/src/clean.ts CHANGED
@@ -1,12 +1,9 @@
1
1
  import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { basename, join } from 'node:path'
3
- import { findAstroConfig } from './utils'
3
+ import { assembleConfig, extractConfigBody, findAstroConfig, removeProperty } from './utils'
4
+ import type { CommandOptions } from './utils'
4
5
 
5
- export interface CleanOptions {
6
- cwd?: string
7
- dryRun?: boolean
8
- yes?: boolean
9
- }
6
+ export type CleanOptions = CommandOptions
10
7
 
11
8
  export type FeatureKey = 'cms' | 'pageMarkdown' | 'mdx' | 'sitemap' | 'tailwindcss' | 'checks'
12
9
  const FEATURE_KEYS: FeatureKey[] = ['cms', 'pageMarkdown', 'mdx', 'sitemap', 'tailwindcss', 'checks']
@@ -43,93 +40,6 @@ export function detectDisabledFeatures(content: string): Set<FeatureKey> {
43
40
  return disabled
44
41
  }
45
42
 
46
- /**
47
- * Find the matching closing brace/bracket/paren, aware of string literals.
48
- */
49
- function findMatchingClose(text: string, start: number): number {
50
- const open = text[start]!
51
- const close = open === '{' ? '}' : open === '[' ? ']' : ')'
52
- let depth = 0
53
- let inString: string | false = false
54
-
55
- for (let i = start; i < text.length; i++) {
56
- const ch = text[i]
57
-
58
- if (inString) {
59
- if (ch === '\\') {
60
- i++
61
- continue
62
- }
63
- if (ch === inString) inString = false
64
- continue
65
- }
66
-
67
- if (ch === "'" || ch === '"' || ch === '`') {
68
- inString = ch
69
- continue
70
- }
71
-
72
- if (ch === open) depth++
73
- if (ch === close) {
74
- depth--
75
- if (depth === 0) return i
76
- }
77
- }
78
-
79
- return -1
80
- }
81
-
82
- /**
83
- * Extract the text between the outermost { } of defineConfig({ ... })
84
- */
85
- export function extractConfigBody(content: string): string {
86
- const match = content.match(/defineConfig\s*\(\s*\{/)
87
- if (!match || match.index === undefined) return ''
88
-
89
- const openBrace = content.indexOf('{', match.index + 'defineConfig'.length)
90
- const closeBrace = findMatchingClose(content, openBrace)
91
- if (closeBrace === -1) return ''
92
-
93
- return content.slice(openBrace + 1, closeBrace)
94
- }
95
-
96
- /**
97
- * Remove a top-level property from an object literal body text.
98
- */
99
- export function removeProperty(body: string, propName: string): string {
100
- const regex = new RegExp(`(\\n[ \\t]*)${propName}\\s*:\\s*`)
101
- const match = regex.exec(body)
102
- if (!match || match.index === undefined) return body
103
-
104
- const propLineStart = match.index + 1 // skip the leading \n
105
- const afterMatch = match.index + match[0].length
106
-
107
- // Skip whitespace (not newlines) before the value
108
- let i = afterMatch
109
- while (i < body.length && (body[i] === ' ' || body[i] === '\t')) i++
110
-
111
- let valueEnd: number
112
-
113
- if (body[i] === '{' || body[i] === '[') {
114
- valueEnd = findMatchingClose(body, i)
115
- if (valueEnd === -1) return body
116
- valueEnd++ // include closing brace/bracket
117
- } else {
118
- // Simple value (false, true, number, string, variable)
119
- while (i < body.length && body[i] !== ',' && body[i] !== '\n') i++
120
- valueEnd = i
121
- }
122
-
123
- // Skip trailing comma and whitespace up to newline
124
- let end = valueEnd
125
- while (end < body.length && (body[end] === ' ' || body[end] === '\t')) end++
126
- if (end < body.length && body[end] === ',') end++
127
- while (end < body.length && (body[end] === ' ' || body[end] === '\t')) end++
128
- if (end < body.length && body[end] === '\n') end++
129
-
130
- return body.slice(0, propLineStart) + body.slice(end)
131
- }
132
-
133
43
  /**
134
44
  * Prepend items into an existing array property (e.g. `integrations: [` or `plugins: [`).
135
45
  * Mutates `lines` in place. Returns true if a merge happened.
@@ -205,14 +115,7 @@ export function transformConfig(content: string, disabled: Set<FeatureKey>): str
205
115
 
206
116
  const allLines = [...bodyLines, ...newPropLines]
207
117
 
208
- let result = imports.join('\n') + '\n\n'
209
- result += 'export default defineConfig({\n'
210
- if (allLines.length > 0) {
211
- result += allLines.join('\n') + '\n'
212
- }
213
- result += '})\n'
214
-
215
- return result
118
+ return assembleConfig(imports, allLines.join('\n'))
216
119
  }
217
120
 
218
121
  export function transformPackageJson(
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ function printUsage() {
38
38
  console.log(' build Run astro build with the Nua defaults')
39
39
  console.log(' dev Run astro dev with the Nua defaults')
40
40
  console.log(' preview Run astro preview with the Nua defaults')
41
+ console.log(' init Convert a standard Astro project to use Nua')
41
42
  console.log(' clean Eject to a standard Astro project (remove @nuasite/* deps)')
42
43
  console.log(' help Show this message')
43
44
  console.log('\nAll Astro CLI options are supported.\n')
@@ -86,6 +87,15 @@ if (canProxyDirectly && command && ['build', 'dev', 'preview'].includes(command)
86
87
  })
87
88
  break
88
89
  }
90
+ case 'init': {
91
+ const { init } = await import('./init')
92
+ await init({
93
+ cwd: process.cwd(),
94
+ dryRun: args.includes('--dry-run'),
95
+ yes: args.includes('--yes') || args.includes('-y'),
96
+ })
97
+ break
98
+ }
89
99
  case 'clean': {
90
100
  const { clean } = await import('./clean')
91
101
  await clean({
package/src/init.ts ADDED
@@ -0,0 +1,260 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { assembleConfig, extractConfigBody, findAstroConfig, findMatchingClose } from './utils'
4
+ import type { CommandOptions } from './utils'
5
+
6
+ export type InitOptions = CommandOptions
7
+
8
+ const NUA_PROVIDED_PACKAGES = [
9
+ '@astrojs/mdx',
10
+ '@astrojs/sitemap',
11
+ '@tailwindcss/vite',
12
+ 'tailwindcss',
13
+ ]
14
+
15
+ const NUA_MANAGED_PACKAGES = [
16
+ '@astrojs/mdx',
17
+ '@astrojs/sitemap',
18
+ '@tailwindcss/vite',
19
+ ]
20
+
21
+ /**
22
+ * Detect which Nua-managed packages are explicitly imported in the config.
23
+ * Returns a map of package specifier → local import name.
24
+ */
25
+ export function detectNuaManagedImports(content: string): Map<string, string> {
26
+ const managed = new Map<string, string>()
27
+ for (const pkg of NUA_MANAGED_PACKAGES) {
28
+ const regex = new RegExp(`import\\s+(\\w+)\\s+from\\s+['"]${pkg.replace('/', '\\/')}['"]`)
29
+ const match = regex.exec(content)
30
+ if (match) {
31
+ managed.set(pkg, match[1]!)
32
+ }
33
+ }
34
+ return managed
35
+ }
36
+
37
+ /**
38
+ * Remove a function call (e.g. `mdx()` or `sitemap({ ... })`) from an array property.
39
+ */
40
+ export function removeCallFromArray(body: string, arrayProp: string, callName: string): string {
41
+ const propRegex = new RegExp(`\\b${arrayProp}\\s*:\\s*\\[`)
42
+ const propMatch = propRegex.exec(body)
43
+ if (!propMatch || propMatch.index === undefined) return body
44
+
45
+ const arrayStart = body.indexOf('[', propMatch.index)
46
+ const arrayEnd = findMatchingClose(body, arrayStart)
47
+ if (arrayEnd === -1) return body
48
+
49
+ const arrayContent = body.slice(arrayStart + 1, arrayEnd)
50
+
51
+ const callRegex = new RegExp(`\\b${callName}\\s*\\(`)
52
+ const callMatch = callRegex.exec(arrayContent)
53
+ if (!callMatch || callMatch.index === undefined) return body
54
+
55
+ const parenStart = arrayContent.indexOf('(', callMatch.index)
56
+ const parenEnd = findMatchingClose(arrayContent, parenStart)
57
+ if (parenEnd === -1) return body
58
+
59
+ let removeStart = callMatch.index
60
+ let removeEnd = parenEnd + 1
61
+
62
+ let after = removeEnd
63
+ while (after < arrayContent.length && (arrayContent[after] === ' ' || arrayContent[after] === '\t' || arrayContent[after] === '\n')) {
64
+ after++
65
+ }
66
+ if (after < arrayContent.length && arrayContent[after] === ',') {
67
+ removeEnd = after + 1
68
+ while (
69
+ removeEnd < arrayContent.length
70
+ && (arrayContent[removeEnd] === ' ' || arrayContent[removeEnd] === '\t' || arrayContent[removeEnd] === '\n')
71
+ ) removeEnd++
72
+ }
73
+
74
+ if (removeEnd === parenEnd + 1) {
75
+ let before = removeStart - 1
76
+ while (before >= 0 && (arrayContent[before] === ' ' || arrayContent[before] === '\t' || arrayContent[before] === '\n')) before--
77
+ if (before >= 0 && arrayContent[before] === ',') {
78
+ removeStart = before
79
+ }
80
+ }
81
+
82
+ const newArrayContent = arrayContent.slice(0, removeStart) + arrayContent.slice(removeEnd)
83
+ return body.slice(0, arrayStart + 1) + newArrayContent + body.slice(arrayEnd)
84
+ }
85
+
86
+ /**
87
+ * Clean up empty structures left after removing managed integrations/plugins.
88
+ * Removes empty arrays/objects and the Nua-default `sourcemap: true`.
89
+ */
90
+ export function cleanEmptyStructures(body: string): string {
91
+ body = body.replace(/\n[ \t]*sourcemap\s*:\s*true\s*,?[ \t]*/g, '\n')
92
+ body = body.replace(/\n[ \t]*build\s*:\s*\{[\s,]*\}\s*,?[ \t]*/g, '\n')
93
+ body = body.replace(/\n[ \t]*plugins\s*:\s*\[[\s,]*\]\s*,?[ \t]*/g, '\n')
94
+ body = body.replace(/\n[ \t]*integrations\s*:\s*\[[\s,]*\]\s*,?[ \t]*/g, '\n')
95
+ body = body.replace(/\n[ \t]*vite\s*:\s*\{[\s,]*\}\s*,?[ \t]*/g, '\n')
96
+
97
+ return body
98
+ }
99
+
100
+ export function transformConfig(content: string, managedImports: Map<string, string>): string {
101
+ const lines = content.split('\n')
102
+ const newImports: string[] = []
103
+
104
+ for (const line of lines) {
105
+ if (!/^\s*import\s/.test(line)) continue
106
+
107
+ if (line.includes('astro/config') && line.includes('defineConfig')) {
108
+ newImports.push(`import { defineConfig } from '@nuasite/nua/config'`)
109
+ continue
110
+ }
111
+
112
+ const isManagedImport = [...managedImports.keys()].some(pkg => line.includes(pkg))
113
+ if (isManagedImport) continue
114
+
115
+ newImports.push(line)
116
+ }
117
+
118
+ let body = extractConfigBody(content)
119
+
120
+ for (const [pkg, localName] of managedImports) {
121
+ if (pkg === '@tailwindcss/vite') {
122
+ body = removeCallFromArray(body, 'plugins', localName)
123
+ } else {
124
+ body = removeCallFromArray(body, 'integrations', localName)
125
+ }
126
+ }
127
+
128
+ body = cleanEmptyStructures(body)
129
+
130
+ return assembleConfig(newImports, body)
131
+ }
132
+
133
+ function resolveNuaVersion(): string {
134
+ try {
135
+ const cliPkgPath = new URL('../../package.json', import.meta.url)
136
+ const cliPkg = JSON.parse(readFileSync(cliPkgPath, 'utf-8'))
137
+ const version: string = cliPkg.version
138
+ const [major, minor] = version.split('.')
139
+ return `^${major}.${minor}.0`
140
+ } catch {
141
+ return '^0.17.0'
142
+ }
143
+ }
144
+
145
+ export function transformPackageJson(pkg: Record<string, any>, nuaVersion: string): Record<string, any> {
146
+ const result = structuredClone(pkg)
147
+
148
+ if (result.scripts) {
149
+ for (const [key, value] of Object.entries(result.scripts)) {
150
+ if (typeof value === 'string') {
151
+ result.scripts[key] = value
152
+ .replace(/\bastro build\b/g, 'nua build')
153
+ .replace(/\bastro dev\b/g, 'nua dev')
154
+ .replace(/\bastro preview\b/g, 'nua preview')
155
+ }
156
+ }
157
+ }
158
+
159
+ for (const field of ['dependencies', 'devDependencies'] as const) {
160
+ if (!result[field]) continue
161
+ for (const name of NUA_PROVIDED_PACKAGES) {
162
+ delete result[field][name]
163
+ }
164
+ if (Object.keys(result[field]).length === 0) {
165
+ delete result[field]
166
+ }
167
+ }
168
+
169
+ if (!result.dependencies) result.dependencies = {}
170
+ if (!result.dependencies['@nuasite/nua']) {
171
+ result.dependencies['@nuasite/nua'] = nuaVersion
172
+ }
173
+
174
+ result.dependencies = Object.fromEntries(
175
+ Object.entries(result.dependencies).sort(([a], [b]) => a.localeCompare(b)),
176
+ )
177
+
178
+ return result
179
+ }
180
+
181
+ export async function init({ cwd = process.cwd(), dryRun = false, yes = false }: InitOptions = {}) {
182
+ const configPath = findAstroConfig(cwd)
183
+ if (!configPath) {
184
+ console.error('No Astro config file found.')
185
+ process.exit(1)
186
+ }
187
+
188
+ const configContent = readFileSync(configPath, 'utf-8')
189
+
190
+ if (configContent.includes('@nuasite/nua')) {
191
+ console.log('This project already uses @nuasite/nua. Nothing to do.')
192
+ return
193
+ }
194
+
195
+ if (!configContent.includes('defineConfig')) {
196
+ console.error('Could not find defineConfig in Astro config.')
197
+ process.exit(1)
198
+ }
199
+
200
+ const pkgPath = join(cwd, 'package.json')
201
+ if (!existsSync(pkgPath)) {
202
+ console.error('No package.json found.')
203
+ process.exit(1)
204
+ }
205
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
206
+
207
+ const managedImports = detectNuaManagedImports(configContent)
208
+ const nuaVersion = resolveNuaVersion()
209
+ const configName = basename(configPath)
210
+
211
+ console.log('')
212
+ console.log('nua init \u2014 adopt the Nua toolchain')
213
+ console.log('')
214
+ console.log(` ${configName}`)
215
+ console.log(' - Replace astro/config with @nuasite/nua/config')
216
+ if (managedImports.size > 0) {
217
+ console.log(` - Remove Nua-managed imports: ${[...managedImports.keys()].join(', ')}`)
218
+ console.log(' - Remove managed integration/plugin calls')
219
+ }
220
+ console.log(' - Clean up empty config structures')
221
+ console.log('')
222
+ console.log(' package.json')
223
+ const removable = NUA_PROVIDED_PACKAGES.filter(name => pkg.dependencies?.[name] || pkg.devDependencies?.[name])
224
+ if (removable.length > 0) {
225
+ console.log(` - Remove Nua-provided deps: ${removable.join(', ')}`)
226
+ }
227
+ console.log(` - Add @nuasite/nua ${nuaVersion}`)
228
+ console.log(' - Update scripts: astro \u2192 nua')
229
+
230
+ if (dryRun) {
231
+ console.log('')
232
+ console.log(' (--dry-run: no changes made)')
233
+ console.log('')
234
+ return
235
+ }
236
+
237
+ if (!yes) {
238
+ console.log('')
239
+ const answer = prompt('Proceed? [y/N] ')
240
+ if (answer?.toLowerCase() !== 'y') {
241
+ console.log('Cancelled.')
242
+ return
243
+ }
244
+ }
245
+
246
+ const newConfig = transformConfig(configContent, managedImports)
247
+ writeFileSync(configPath, newConfig)
248
+ console.log(` Updated ${configName}`)
249
+
250
+ const newPkg = transformPackageJson(pkg, nuaVersion)
251
+ writeFileSync(pkgPath, JSON.stringify(newPkg, null, '\t') + '\n')
252
+ console.log(' Updated package.json')
253
+
254
+ console.log('')
255
+ console.log('Next steps:')
256
+ console.log(' 1. bun install')
257
+ console.log(' 2. Review the updated config')
258
+ console.log(' 3. nua dev')
259
+ console.log('')
260
+ }
package/src/utils.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
+ export interface CommandOptions {
5
+ cwd?: string
6
+ dryRun?: boolean
7
+ yes?: boolean
8
+ }
9
+
4
10
  const CONFIG_NAMES = [
5
11
  'astro.config.ts',
6
12
  'astro.config.mts',
@@ -15,3 +21,106 @@ export function findAstroConfig(cwd: string = process.cwd()): string | null {
15
21
  }
16
22
  return null
17
23
  }
24
+
25
+ /**
26
+ * Find the matching closing brace/bracket/paren, aware of string literals.
27
+ */
28
+ export function findMatchingClose(text: string, start: number): number {
29
+ const open = text[start]!
30
+ const close = open === '{' ? '}' : open === '[' ? ']' : ')'
31
+ let depth = 0
32
+ let inString: string | false = false
33
+
34
+ for (let i = start; i < text.length; i++) {
35
+ const ch = text[i]
36
+
37
+ if (inString) {
38
+ if (ch === '\\') {
39
+ i++
40
+ continue
41
+ }
42
+ if (ch === inString) inString = false
43
+ continue
44
+ }
45
+
46
+ if (ch === "'" || ch === '"' || ch === '`') {
47
+ inString = ch
48
+ continue
49
+ }
50
+
51
+ if (ch === open) depth++
52
+ if (ch === close) {
53
+ depth--
54
+ if (depth === 0) return i
55
+ }
56
+ }
57
+
58
+ return -1
59
+ }
60
+
61
+ /**
62
+ * Extract the text between the outermost { } of defineConfig({ ... })
63
+ */
64
+ export function extractConfigBody(content: string): string {
65
+ const match = content.match(/defineConfig\s*\(\s*\{/)
66
+ if (!match || match.index === undefined) return ''
67
+
68
+ const openBrace = content.indexOf('{', match.index + 'defineConfig'.length)
69
+ const closeBrace = findMatchingClose(content, openBrace)
70
+ if (closeBrace === -1) return ''
71
+
72
+ return content.slice(openBrace + 1, closeBrace)
73
+ }
74
+
75
+ /**
76
+ * Remove a top-level property from an object literal body text.
77
+ */
78
+ export function removeProperty(body: string, propName: string): string {
79
+ const regex = new RegExp(`(\\n[ \\t]*)${propName}\\s*:\\s*`)
80
+ const match = regex.exec(body)
81
+ if (!match || match.index === undefined) return body
82
+
83
+ const propLineStart = match.index + 1 // skip the leading \n
84
+ const afterMatch = match.index + match[0].length
85
+
86
+ // Skip whitespace (not newlines) before the value
87
+ let i = afterMatch
88
+ while (i < body.length && (body[i] === ' ' || body[i] === '\t')) i++
89
+
90
+ let valueEnd: number
91
+
92
+ if (body[i] === '{' || body[i] === '[') {
93
+ valueEnd = findMatchingClose(body, i)
94
+ if (valueEnd === -1) return body
95
+ valueEnd++ // include closing brace/bracket
96
+ } else {
97
+ // Simple value (false, true, number, string, variable)
98
+ while (i < body.length && body[i] !== ',' && body[i] !== '\n') i++
99
+ valueEnd = i
100
+ }
101
+
102
+ // Skip trailing comma and whitespace up to newline
103
+ let end = valueEnd
104
+ while (end < body.length && (body[end] === ' ' || body[end] === '\t')) end++
105
+ if (end < body.length && body[end] === ',') end++
106
+ while (end < body.length && (body[end] === ' ' || body[end] === '\t')) end++
107
+ if (end < body.length && body[end] === '\n') end++
108
+
109
+ return body.slice(0, propLineStart) + body.slice(end)
110
+ }
111
+
112
+ /**
113
+ * Assemble a complete config file from import lines and a body string.
114
+ */
115
+ export function assembleConfig(imports: string[], body: string): string {
116
+ const bodyLines = body.split('\n').filter(line => line.trim() !== '')
117
+
118
+ let result = imports.join('\n') + '\n\n'
119
+ result += 'export default defineConfig({\n'
120
+ if (bodyLines.length > 0) {
121
+ result += bodyLines.join('\n') + '\n'
122
+ }
123
+ result += '})\n'
124
+
125
+ return result
126
+ }