@lightnet/cli 4.2.0 → 4.3.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,456 @@
1
+ // @ts-check
2
+
3
+ import { posix, resolve } from "node:path"
4
+ import { cwd as processCwd, stdin, stdout } from "node:process"
5
+
6
+ import { intro, isCancel, log, outro, text } from "@clack/prompts"
7
+
8
+ import {
9
+ cliConfigFileName,
10
+ readCliConfig,
11
+ writeCliConfig,
12
+ } from "./support/cli-config.js"
13
+ import { CliError } from "./support/cli-error.js"
14
+ import { contentCollections } from "./support/content-collections.js"
15
+ import { pathExists } from "./support/filesystem.js"
16
+
17
+ const mediaDir = "src/content/media"
18
+ const defaultConcurrency = 24
19
+ const defaultTimeoutMs = 10_000
20
+ const headFallbackStatuses = new Set([405, 501])
21
+ const requestHeaders = {
22
+ accept: "*/*",
23
+ "user-agent": "@lightnet/cli link checker",
24
+ }
25
+
26
+ /** @typedef {import("./support/content-collections.js").MediaItem} MediaItem */
27
+
28
+ /**
29
+ * @typedef {{
30
+ * timeout?: string
31
+ * }} CheckLinksOptions
32
+ */
33
+
34
+ /**
35
+ * @typedef {{
36
+ * cwd?: string
37
+ * fetch?: typeof fetch
38
+ * isInteractive?: boolean
39
+ * promptText?: (message: string) => Promise<string>
40
+ * }} CheckLinksRuntime
41
+ */
42
+
43
+ /**
44
+ * @typedef {{
45
+ * displayUrl: string
46
+ * resolvedUrl: string
47
+ * sources: Set<string>
48
+ * }} LinkReference
49
+ */
50
+
51
+ /**
52
+ * @typedef {{
53
+ * ok: boolean
54
+ * reason?: string
55
+ * status?: number
56
+ * }} LinkCheckResult
57
+ */
58
+
59
+ /**
60
+ * @param {CheckLinksOptions} options
61
+ * @param {CheckLinksRuntime} [runtime]
62
+ */
63
+ export async function checkLinks(options, runtime = {}) {
64
+ const cwd = runtime.cwd ?? processCwd()
65
+ const interactive =
66
+ runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
67
+ const promptText =
68
+ runtime.promptText ?? (async (message) => defaultPromptText(message))
69
+ const runFetch = runtime.fetch ?? fetch
70
+ const timeoutMs = parsePositiveInteger(
71
+ options.timeout,
72
+ "--timeout",
73
+ defaultTimeoutMs,
74
+ )
75
+
76
+ intro("check-links")
77
+
78
+ await assertLightNetSiteRoot(cwd)
79
+
80
+ const collections = contentCollections(cwd)
81
+ const mediaItems = await collections.getMediaItems()
82
+ const siteUrl = await initSiteUrl({
83
+ cwd,
84
+ interactive,
85
+ mediaItems,
86
+ promptText,
87
+ })
88
+ const references = collectLinkReferences(mediaItems, siteUrl)
89
+ log.message(
90
+ `Checking ${references.length} unique links from ${mediaItems.length} media items.`,
91
+ )
92
+
93
+ if (references.length === 0) {
94
+ log.warn("No media content URLs found.")
95
+ outro("No issues found. 🎉")
96
+ return true
97
+ }
98
+
99
+ const results = await mapConcurrent(
100
+ references,
101
+ defaultConcurrency,
102
+ (reference) =>
103
+ checkLink(reference.resolvedUrl, { fetch: runFetch, timeoutMs }),
104
+ )
105
+ const checkedLinks = references
106
+ .map((reference, index) => ({ reference, result: results[index] }))
107
+ .sort((a, b) =>
108
+ a.reference.displayUrl.localeCompare(b.reference.displayUrl),
109
+ )
110
+ const protectedLinks = checkedLinks.filter((item) =>
111
+ isProtectedLink(item.result),
112
+ )
113
+ const unavailableLinks = checkedLinks.filter(
114
+ (item) => !item.result.ok && !isProtectedLink(item.result),
115
+ )
116
+
117
+ printProtectedLinks(protectedLinks)
118
+
119
+ if (unavailableLinks.length === 0) {
120
+ if (protectedLinks.length > 0) {
121
+ outro("No unavailable links found. Some links could not be verified. ⚠️")
122
+ return true
123
+ }
124
+ outro("No issues found. 🎉")
125
+ return true
126
+ }
127
+
128
+ log.error(`Unavailable links (${unavailableLinks.length})`)
129
+ for (const { reference, result } of unavailableLinks) {
130
+ log.message(
131
+ `· ${reference.displayUrl} (${formatFailure(result)}, referenced by ${[
132
+ ...reference.sources,
133
+ ]
134
+ .toSorted()
135
+ .join(", ")})`,
136
+ )
137
+ }
138
+
139
+ outro("Issues found. 🚧")
140
+
141
+ return false
142
+ }
143
+
144
+ /**
145
+ * @param {{
146
+ * cwd: string
147
+ * interactive: boolean
148
+ * mediaItems: MediaItem[]
149
+ * promptText: (message: string) => Promise<string>
150
+ * }} args
151
+ */
152
+ async function initSiteUrl({ cwd, interactive, mediaItems, promptText }) {
153
+ const needsSiteUrl = mediaItems.some((item) =>
154
+ item.content.some((contentItem) => isRootRelativeUrl(contentItem.url)),
155
+ )
156
+ if (!needsSiteUrl) {
157
+ return undefined
158
+ }
159
+
160
+ const config = await readCliConfig(cwd)
161
+ const configuredSiteUrl = parseSiteUrl(config)
162
+ if (configuredSiteUrl) {
163
+ return configuredSiteUrl
164
+ }
165
+
166
+ if (!interactive) {
167
+ throw new CliError(
168
+ `Root-relative media content URLs require a site URL. Add "siteUrl" to "${cliConfigFileName}".`,
169
+ )
170
+ }
171
+
172
+ log.error(`Missing siteUrl in "${cliConfigFileName}".`)
173
+ while (true) {
174
+ const value = (await promptText("Site URL: ")).trim()
175
+ if (!value) {
176
+ continue
177
+ }
178
+ const siteUrl = normalizeSiteUrl(value, "Site URL")
179
+ await writeCliConfig(cwd, {
180
+ ...(isPlainObject(config) ? config : {}),
181
+ siteUrl,
182
+ })
183
+ return siteUrl
184
+ }
185
+ }
186
+
187
+ /**
188
+ * @param {unknown} config
189
+ */
190
+ function parseSiteUrl(config) {
191
+ if (!isPlainObject(config)) {
192
+ return undefined
193
+ }
194
+ const siteUrl = /** @type {Record<string, unknown>} */ (config).siteUrl
195
+ if (typeof siteUrl !== "string" || !siteUrl.trim()) {
196
+ return undefined
197
+ }
198
+ return normalizeSiteUrl(siteUrl, `"${cliConfigFileName}" siteUrl`)
199
+ }
200
+
201
+ /**
202
+ * @param {MediaItem[]} mediaItems
203
+ * @param {string|undefined} siteUrl
204
+ */
205
+ function collectLinkReferences(mediaItems, siteUrl) {
206
+ /** @type {Map<string, LinkReference>} */
207
+ const references = new Map()
208
+ for (const item of mediaItems) {
209
+ const sourceFileName = posix.basename(toPosixPath(item.path))
210
+ for (const contentItem of item.content) {
211
+ const resolvedUrl = resolveUrl(contentItem.url, siteUrl)
212
+ const current = references.get(resolvedUrl)
213
+ if (current) {
214
+ current.sources.add(sourceFileName)
215
+ if (contentItem.url.length < current.displayUrl.length) {
216
+ current.displayUrl = contentItem.url
217
+ }
218
+ } else {
219
+ references.set(resolvedUrl, {
220
+ displayUrl: contentItem.url,
221
+ resolvedUrl,
222
+ sources: new Set([sourceFileName]),
223
+ })
224
+ }
225
+ }
226
+ }
227
+ return [...references.values()].sort((a, b) =>
228
+ a.displayUrl.localeCompare(b.displayUrl),
229
+ )
230
+ }
231
+
232
+ /**
233
+ * @param {string} url
234
+ * @param {string|undefined} siteUrl
235
+ */
236
+ function resolveUrl(url, siteUrl) {
237
+ if (isRootRelativeUrl(url)) {
238
+ if (!siteUrl) {
239
+ throw new CliError(`Root-relative URL "${url}" requires a site URL.`)
240
+ }
241
+ const parsed = new URL(url, siteUrl)
242
+ parsed.hash = ""
243
+ return parsed.toString()
244
+ }
245
+
246
+ let parsed
247
+ try {
248
+ parsed = new URL(url)
249
+ } catch {
250
+ throw new CliError(`Invalid media content URL "${url}".`)
251
+ }
252
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
253
+ throw new CliError(
254
+ `Unsupported media content URL "${url}". Expected http or https.`,
255
+ )
256
+ }
257
+ parsed.hash = ""
258
+ return parsed.toString()
259
+ }
260
+
261
+ /**
262
+ * @param {string} url
263
+ * @param {{fetch: typeof fetch, timeoutMs: number}} options
264
+ * @returns {Promise<LinkCheckResult>}
265
+ */
266
+ async function checkLink(url, options) {
267
+ const headResult = await fetchStatus(url, {
268
+ ...options,
269
+ headers: requestHeaders,
270
+ method: "HEAD",
271
+ })
272
+ if (headResult.ok || !headFallbackStatuses.has(headResult.status ?? 0)) {
273
+ return headResult
274
+ }
275
+ return fetchStatus(url, {
276
+ ...options,
277
+ headers: {
278
+ ...requestHeaders,
279
+ "accept-encoding": "identity",
280
+ range: "bytes=0-0",
281
+ },
282
+ method: "GET",
283
+ })
284
+ }
285
+
286
+ /**
287
+ * @param {{reference: LinkReference, result: LinkCheckResult}[]} links
288
+ */
289
+ function printProtectedLinks(links) {
290
+ if (links.length === 0) {
291
+ return
292
+ }
293
+
294
+ log.warn(`Protected or forbidden links (${links.length})`)
295
+ for (const { reference, result } of links) {
296
+ log.message(
297
+ `· ${reference.displayUrl} (${formatFailure(result)}, referenced by ${[
298
+ ...reference.sources,
299
+ ]
300
+ .toSorted()
301
+ .join(", ")})`,
302
+ )
303
+ }
304
+ }
305
+
306
+ /**
307
+ * @param {string} url
308
+ * @param {{
309
+ * fetch: typeof fetch
310
+ * headers?: Record<string, string>
311
+ * method: "GET"|"HEAD"
312
+ * timeoutMs: number
313
+ * }} options
314
+ * @returns {Promise<LinkCheckResult>}
315
+ */
316
+ async function fetchStatus(url, options) {
317
+ const controller = new AbortController()
318
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs)
319
+ try {
320
+ const response = await options.fetch(url, {
321
+ headers: options.headers,
322
+ method: options.method,
323
+ redirect: "follow",
324
+ signal: controller.signal,
325
+ })
326
+ await response.body?.cancel()
327
+ const ok = response.status >= 200 && response.status < 400
328
+ return { ok, status: response.status }
329
+ } catch (error) {
330
+ return {
331
+ ok: false,
332
+ reason: error instanceof Error ? error.message : "Request failed",
333
+ }
334
+ } finally {
335
+ clearTimeout(timeout)
336
+ }
337
+ }
338
+
339
+ /**
340
+ * @template T
341
+ * @template R
342
+ * @param {T[]} items
343
+ * @param {number} concurrency
344
+ * @param {(item: T) => Promise<R>} mapper
345
+ * @returns {Promise<R[]>}
346
+ */
347
+ async function mapConcurrent(items, concurrency, mapper) {
348
+ /** @type {R[]} */
349
+ const results = []
350
+ let nextIndex = 0
351
+ const workerCount = Math.min(concurrency, items.length)
352
+ await Promise.all(
353
+ Array.from({ length: workerCount }, async () => {
354
+ while (true) {
355
+ const index = nextIndex
356
+ nextIndex += 1
357
+ const item = items[index]
358
+ if (item === undefined) {
359
+ return
360
+ }
361
+ results[index] = await mapper(item)
362
+ }
363
+ }),
364
+ )
365
+ return results
366
+ }
367
+
368
+ /**
369
+ * @param {string|undefined} rawValue
370
+ * @param {string} optionName
371
+ * @param {number} defaultValue
372
+ */
373
+ function parsePositiveInteger(rawValue, optionName, defaultValue) {
374
+ if (rawValue === undefined) {
375
+ return defaultValue
376
+ }
377
+ const value = Number(rawValue)
378
+ if (!Number.isInteger(value) || value < 1) {
379
+ throw new CliError(`Expected "${optionName}" to be a positive integer.`)
380
+ }
381
+ return value
382
+ }
383
+
384
+ /**
385
+ * @param {string} value
386
+ * @param {string} label
387
+ */
388
+ function normalizeSiteUrl(value, label) {
389
+ let parsed
390
+ try {
391
+ parsed = new URL(value)
392
+ } catch {
393
+ throw new CliError(`Invalid ${label}: expected an absolute http(s) URL.`)
394
+ }
395
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
396
+ throw new CliError(`Invalid ${label}: expected an absolute http(s) URL.`)
397
+ }
398
+ return parsed.toString().replace(/\/+$/, "")
399
+ }
400
+
401
+ /**
402
+ * @param {string} cwd
403
+ */
404
+ async function assertLightNetSiteRoot(cwd) {
405
+ if (!(await pathExists(resolve(cwd, mediaDir)))) {
406
+ throw new CliError(
407
+ 'Expected a LightNet site root with "src/content/media". Run this command inside a LightNet site project.',
408
+ )
409
+ }
410
+ }
411
+
412
+ /**
413
+ * @param {string} url
414
+ */
415
+ function isRootRelativeUrl(url) {
416
+ return url.startsWith("/") && !url.startsWith("//")
417
+ }
418
+
419
+ /**
420
+ * @param {LinkCheckResult} result
421
+ */
422
+ function formatFailure(result) {
423
+ if (result.status !== undefined) {
424
+ return `status ${result.status}`
425
+ }
426
+ return result.reason ?? "request failed"
427
+ }
428
+
429
+ /**
430
+ * @param {LinkCheckResult} result
431
+ */
432
+ function isProtectedLink(result) {
433
+ return !result.ok && result.status === 403
434
+ }
435
+
436
+ /**
437
+ * @param {string} filePath
438
+ */
439
+ function toPosixPath(filePath) {
440
+ return filePath.split("\\").join("/")
441
+ }
442
+
443
+ /**
444
+ * @param {string} message
445
+ */
446
+ async function defaultPromptText(message) {
447
+ const answer = await text({ message })
448
+ return isCancel(answer) ? "" : String(answer)
449
+ }
450
+
451
+ /**
452
+ * @param {unknown} value
453
+ */
454
+ function isPlainObject(value) {
455
+ return typeof value === "object" && value !== null && !Array.isArray(value)
456
+ }
@@ -1,9 +1,12 @@
1
1
  // @ts-check
2
2
 
3
+ import { spawn } from "node:child_process"
3
4
  import { readFile } from "node:fs/promises"
4
5
  import { resolve } from "node:path"
5
6
  import { cwd } from "node:process"
6
7
 
8
+ import { confirm, intro, log, outro, taskLog } from "@clack/prompts"
9
+
7
10
  /**
8
11
  * @typedef {{
9
12
  * type: "lightnet" | "user" | "map"
@@ -25,23 +28,30 @@ const lightnetCachePath = resolve(cwd(), "node_modules", ".cache", "lightnet")
25
28
  const translationSources = [
26
29
  {
27
30
  type: "lightnet",
28
- title: "LightNet built-in translations",
31
+ title: "Missing LightNet built-in translations",
29
32
  action: "Add the missing entries in your /src/translations/*.yaml files.",
30
33
  },
31
34
  {
32
35
  type: "user",
33
- title: "User translation files",
36
+ title: "Incomplete user translations",
34
37
  action: "Add the missing entries in your /src/translations/*.yaml files.",
35
38
  },
36
39
  {
37
40
  type: "map",
38
- title: "Inline translation maps",
41
+ title: "Incomplete inline translation maps",
39
42
  action:
40
43
  "Update the inline translation map to include values for every configured site language.",
41
44
  },
42
45
  ]
43
46
 
44
47
  export async function checkTranslations() {
48
+ intro("check-translations")
49
+
50
+ const buildAvailable = await runBuild()
51
+ if (!buildAvailable) {
52
+ outro("Build failed. 🚧")
53
+ return false
54
+ }
45
55
  const translations = await readTranslations()
46
56
  const languages = await readLanguages()
47
57
  if (!translations || !languages || translations.length === 0) {
@@ -55,6 +65,7 @@ export async function checkTranslations() {
55
65
  .filter((translation) => translation.missingLocales.length > 0)
56
66
 
57
67
  if (incompleteTranslations.length === 0) {
68
+ outro("No issues found. 🎉")
58
69
  return true
59
70
  }
60
71
 
@@ -63,15 +74,73 @@ export async function checkTranslations() {
63
74
  (translation) => translation.type,
64
75
  )
65
76
 
66
- console.log("Translation check failed")
77
+ log.error("Translation check failed")
67
78
  for (const source of translationSources) {
68
79
  printMissingTranslations(source, grouped[source.type])
69
80
  }
70
81
 
71
- console.log("\n\n")
82
+ outro("Issues found. 🚧")
83
+
72
84
  return false
73
85
  }
74
86
 
87
+ async function runBuild() {
88
+ const shouldRunBuild = await confirm({
89
+ message:
90
+ "Run pnpm build now? Command requires an up-to-date dist/ directory.",
91
+ initialValue: false,
92
+ })
93
+ if (!shouldRunBuild) {
94
+ return true
95
+ }
96
+ const buildLog = taskLog({
97
+ title: "Running pnpm build",
98
+ })
99
+
100
+ const child = spawn("pnpm", ["build"], {
101
+ shell: process.platform === "win32",
102
+ })
103
+
104
+ child.stdout?.setEncoding("utf8")
105
+ child.stderr?.setEncoding("utf8")
106
+
107
+ child.stdout?.on("data", (chunk) => {
108
+ for (const line of chunk.trimEnd().split("\n")) {
109
+ if (line) {
110
+ buildLog.message(line)
111
+ }
112
+ }
113
+ })
114
+
115
+ child.stderr?.on("data", (chunk) => {
116
+ for (const line of chunk.trimEnd().split("\n")) {
117
+ if (line) {
118
+ buildLog.message(line)
119
+ }
120
+ }
121
+ })
122
+
123
+ try {
124
+ await new Promise((resolve, reject) => {
125
+ child.on("error", reject)
126
+
127
+ child.on("close", (code) => {
128
+ if (code === 0) {
129
+ resolve(0)
130
+ } else {
131
+ reject(new Error(`pnpm build failed with exit code ${code}`))
132
+ }
133
+ })
134
+ })
135
+ buildLog.success("Build completed")
136
+ return true
137
+ } catch (e) {
138
+ buildLog.message(`${e}`)
139
+ buildLog.error("pnpm build failed")
140
+ return false
141
+ }
142
+ }
143
+
75
144
  /**
76
145
  *
77
146
  * @param {{title:string, action:string}} source
@@ -82,8 +151,7 @@ function printMissingTranslations(source, translations) {
82
151
  return
83
152
  }
84
153
 
85
- console.log()
86
- console.log(source.title)
154
+ log.warn(source.title)
87
155
  translations
88
156
  .toSorted(
89
157
  (t1, t2) =>
@@ -91,11 +159,10 @@ function printMissingTranslations(source, translations) {
91
159
  t1.key.localeCompare(t2.key),
92
160
  )
93
161
  .forEach(({ key, missingLocales }) => {
94
- console.log(`- ${key}`)
95
- console.log(` Missing locales: ${missingLocales.join(", ")}`)
162
+ log.message(`• ${key} > Missing: ${missingLocales.join(", ")}`)
96
163
  })
97
164
 
98
- console.log(`Action: ${source.action}`)
165
+ log.message(`Action: ${source.action}`)
99
166
  }
100
167
 
101
168
  /**
@@ -122,10 +189,8 @@ async function readTranslations() {
122
189
  .filter((line) => line.trim())
123
190
  .map((line) => JSON.parse(line))
124
191
  } catch {
125
- console.error("No translation build cache found.")
126
- console.error(
127
- "Action: Run build and try lightnet check-translations again.",
128
- )
192
+ log.error("No translation build cache found.")
193
+ log.error("Action: Run build and try lightnet check-translations again.")
129
194
  return undefined
130
195
  }
131
196
  }
@@ -141,10 +206,8 @@ async function readLanguages() {
141
206
  )
142
207
  return JSON.parse(languagesText)
143
208
  } catch {
144
- console.error("No language manifest found from the last build.")
145
- console.error(
146
- "Action: Run build and try lightnet check-translations again.",
147
- )
209
+ log.error("No language manifest found from the last build.")
210
+ log.error("Action: Run build and try lightnet check-translations again.")
148
211
  return undefined
149
212
  }
150
213
  }
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { Command } from "commander"
5
5
 
6
6
  import pkg from "../package.json" with { type: "json" }
7
7
  import { checkFiles } from "./check-files.js"
8
+ import { checkLinks } from "./check-links.js"
8
9
  import { checkTranslations } from "./check-translations.js"
9
10
  import { CliError } from "./support/cli-error.js"
10
11
  const { version } = pkg
@@ -54,5 +55,23 @@ program
54
55
  }
55
56
  })
56
57
 
58
+ program
59
+ .command("check-links")
60
+ .description("check media content links in a LightNet site")
61
+ .option("--timeout <ms>", "request timeout per link in milliseconds")
62
+ .action(async (options) => {
63
+ try {
64
+ const checkSuccessful = await checkLinks(options)
65
+ commandExitCode = checkSuccessful ? 0 : 1
66
+ } catch (error) {
67
+ if (error instanceof CliError) {
68
+ console.error(error.message)
69
+ commandExitCode = 1
70
+ return
71
+ }
72
+ throw error
73
+ }
74
+ })
75
+
57
76
  await program.parseAsync()
58
77
  process.exitCode = commandExitCode
@@ -0,0 +1,57 @@
1
+ // @ts-check
2
+
3
+ import { readFile, writeFile } from "node:fs/promises"
4
+ import { resolve } from "node:path"
5
+
6
+ import { CliError } from "./cli-error.js"
7
+ import { pathExists } from "./filesystem.js"
8
+
9
+ export const cliConfigFileName = ".lightnet-cli.config.json"
10
+
11
+ const gitIgnoreEntry = `${cliConfigFileName}\n`
12
+
13
+ /**
14
+ * @param {string} cwd
15
+ */
16
+ export async function readCliConfig(cwd) {
17
+ const filePath = resolve(cwd, cliConfigFileName)
18
+ if (!(await pathExists(filePath))) {
19
+ return undefined
20
+ }
21
+ const contents = await readFile(filePath, "utf8")
22
+ try {
23
+ return JSON.parse(contents)
24
+ } catch {
25
+ throw new CliError(`Invalid JSON in "${cliConfigFileName}".`)
26
+ }
27
+ }
28
+
29
+ /**
30
+ * @param {string} cwd
31
+ * @param {Record<string, unknown>} config
32
+ */
33
+ export async function writeCliConfig(cwd, config) {
34
+ await writeFile(
35
+ resolve(cwd, cliConfigFileName),
36
+ `${JSON.stringify(config, null, 2)}\n`,
37
+ "utf8",
38
+ )
39
+ await ensureGitignoreContainsConfig(cwd)
40
+ }
41
+
42
+ /**
43
+ * @param {string} cwd
44
+ */
45
+ async function ensureGitignoreContainsConfig(cwd) {
46
+ const filePath = resolve(cwd, ".gitignore")
47
+ const contents = (await pathExists(filePath))
48
+ ? await readFile(filePath, "utf8")
49
+ : ""
50
+ if (contents.includes(cliConfigFileName)) {
51
+ return
52
+ }
53
+ const next = contents
54
+ ? `${contents}${contents.endsWith("\n") ? "" : "\n"}${gitIgnoreEntry}`
55
+ : gitIgnoreEntry
56
+ await writeFile(filePath, next, "utf8")
57
+ }