@lightnet/cli 4.7.1 → 4.8.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @lightnet/cli
2
2
 
3
+ ## 4.8.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#454](https://github.com/LightNetDev/LightNet/pull/454) [`5de5b93`](https://github.com/LightNetDev/LightNet/commit/5de5b931575a9e864c5acfdf991e0e9b2980030b) - Rename check-files scope `thumbnails` to `images`.
8
+
9
+ ## 4.8.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#449](https://github.com/LightNetDev/LightNet/pull/449) [`b4b1ba9`](https://github.com/LightNetDev/LightNet/commit/b4b1ba9e591af8f6870f2b3eb04029820a008393) - Add repeatable `--exclude` glob exclusions to `check-links` for explicitly exempt media URLs.
14
+
15
+ ### Patch Changes
16
+
17
+ - [#449](https://github.com/LightNetDev/LightNet/pull/449) [`b4b1ba9`](https://github.com/LightNetDev/LightNet/commit/b4b1ba9e591af8f6870f2b3eb04029820a008393) - Update dependencies
18
+
19
+ - [#449](https://github.com/LightNetDev/LightNet/pull/449) [`b4b1ba9`](https://github.com/LightNetDev/LightNet/commit/b4b1ba9e591af8f6870f2b3eb04029820a008393) - Support non interactive usage of check-translations cli script.
20
+
21
+ - [#449](https://github.com/LightNetDev/LightNet/pull/449) [`b4b1ba9`](https://github.com/LightNetDev/LightNet/commit/b4b1ba9e591af8f6870f2b3eb04029820a008393) - Internal fix typings after dependency update.
22
+
3
23
  ## 4.7.1
4
24
 
5
25
  ### Patch Changes
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "CLI for managing LightNet projects",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "version": "4.7.1",
6
+ "version": "4.8.1",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -25,11 +25,11 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "dependencies": {
28
- "@clack/prompts": "^1.7.0",
28
+ "@clack/prompts": "^1.8.0",
29
29
  "commander": "^15.0.0"
30
30
  },
31
31
  "devDependencies": {
32
- "vitest": "^4.1.9"
32
+ "vitest": "^5.0.0"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=22"
@@ -24,7 +24,7 @@ import {
24
24
  import { cancelPrompt } from "./support/prompt-cancel.js"
25
25
  import { createR2FileStorage } from "./support/r2.js"
26
26
 
27
- const supportedScopes = new Set(["content-files", "thumbnails"])
27
+ const supportedScopes = new Set(["content-files", "images"])
28
28
 
29
29
  const mediaDir = "src/content/media"
30
30
  const mediaImagesDir = "src/content/media/images"
@@ -83,7 +83,7 @@ export async function checkFiles(options, runtime = {}) {
83
83
 
84
84
  const scopes = parseScopes(options.scope)
85
85
  const includeContentFiles = scopes.has("content-files")
86
- const includeThumbnails = scopes.has("thumbnails")
86
+ const includeImages = scopes.has("images")
87
87
 
88
88
  intro("check-files")
89
89
 
@@ -102,9 +102,9 @@ export async function checkFiles(options, runtime = {}) {
102
102
  /** @type {string[]} */
103
103
  let orphanedContentFiles = []
104
104
  /** @type {string[]} */
105
- let orphanedMediaThumbnails = []
105
+ let orphanedMediaImages = []
106
106
  /** @type {string[]} */
107
- let orphanedCategoryThumbnails = []
107
+ let orphanedCategoryImages = []
108
108
  /** @type {string[]} */
109
109
  let removedItems = []
110
110
  /** @type {string[]} */
@@ -112,8 +112,8 @@ export async function checkFiles(options, runtime = {}) {
112
112
 
113
113
  /** @type {FileStorage|undefined} */
114
114
  let contentFileStorage = undefined
115
- const mediaThumbnailStorage = files(cwd, { rootDir: mediaImagesDir })
116
- const categoryThumbnailStorage = files(cwd, { rootDir: categoryImagesDir })
115
+ const mediaImageStorage = files(cwd, { rootDir: mediaImagesDir })
116
+ const categoryImageStorage = files(cwd, { rootDir: categoryImagesDir })
117
117
 
118
118
  if (includeContentFiles) {
119
119
  contentFileStorage = options.r2
@@ -153,7 +153,7 @@ export async function checkFiles(options, runtime = {}) {
153
153
  orphanedContentFiles = r2Result.orphanedFiles
154
154
  if (r2Result.referenceCount === 0) {
155
155
  warnings.push(
156
- 'No R2-backed content file references found. Use "--scope=thumbnails" or check your "--r2" setup.',
156
+ 'No R2-backed content file references found. Use "--scope=images" or check your "--r2" setup.',
157
157
  )
158
158
  }
159
159
  } else {
@@ -177,21 +177,21 @@ export async function checkFiles(options, runtime = {}) {
177
177
  }
178
178
  }
179
179
 
180
- if (includeThumbnails) {
181
- const thumbnailsResult = await runSpinner({
182
- error: "Thumbnail check failed.",
183
- start: "Checking thumbnails",
184
- stop: (result) => formatThumbnailCheckSummary(result),
180
+ if (includeImages) {
181
+ const imagesResult = await runSpinner({
182
+ error: "Image check failed.",
183
+ start: "Checking images",
184
+ stop: (result) => formatImageCheckSummary(result),
185
185
  task: () =>
186
- validateThumbnails(
186
+ validateImages(
187
187
  mediaItems,
188
188
  categories,
189
- mediaThumbnailStorage,
190
- categoryThumbnailStorage,
189
+ mediaImageStorage,
190
+ categoryImageStorage,
191
191
  ),
192
192
  })
193
- orphanedMediaThumbnails = thumbnailsResult.orphanedMediaThumbnails
194
- orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
193
+ orphanedMediaImages = imagesResult.orphanedMediaImages
194
+ orphanedCategoryImages = imagesResult.orphanedCategoryImages
195
195
  }
196
196
 
197
197
  if (options.fix || options.fixWithoutConfirm) {
@@ -212,7 +212,7 @@ export async function checkFiles(options, runtime = {}) {
212
212
  contentDeletions.push(deletion)
213
213
  deletions.push(deletion)
214
214
  }
215
- for (const filePath of orphanedMediaThumbnails) {
215
+ for (const filePath of orphanedMediaImages) {
216
216
  const deletion = {
217
217
  displayPath: filePath,
218
218
  target: filePath,
@@ -220,7 +220,7 @@ export async function checkFiles(options, runtime = {}) {
220
220
  localDeletions.push(deletion)
221
221
  deletions.push(deletion)
222
222
  }
223
- for (const filePath of orphanedCategoryThumbnails) {
223
+ for (const filePath of orphanedCategoryImages) {
224
224
  const deletion = {
225
225
  displayPath: filePath,
226
226
  target: filePath,
@@ -257,22 +257,22 @@ export async function checkFiles(options, runtime = {}) {
257
257
  task: async () => {
258
258
  const [
259
259
  deletedContentTargets,
260
- deletedMediaThumbnailTargets,
261
- deletedCategoryThumbnailTargets,
260
+ deletedMediaImageTargets,
261
+ deletedCategoryImageTargets,
262
262
  ] = await Promise.all([
263
263
  contentFileStorage
264
264
  ? contentFileStorage.delete(
265
265
  contentDeletions.map((deletion) => deletion.target),
266
266
  )
267
267
  : [],
268
- mediaThumbnailStorage.delete(
268
+ mediaImageStorage.delete(
269
269
  localDeletions
270
270
  .filter((deletion) =>
271
271
  deletion.target.startsWith(mediaImagesDir),
272
272
  )
273
273
  .map((deletion) => deletion.target),
274
274
  ),
275
- categoryThumbnailStorage.delete(
275
+ categoryImageStorage.delete(
276
276
  localDeletions
277
277
  .filter((deletion) =>
278
278
  deletion.target.startsWith(categoryImagesDir),
@@ -283,8 +283,8 @@ export async function checkFiles(options, runtime = {}) {
283
283
  return {
284
284
  deletedContentTargets,
285
285
  deletedLocalTargets: [
286
- ...deletedMediaThumbnailTargets,
287
- ...deletedCategoryThumbnailTargets,
286
+ ...deletedMediaImageTargets,
287
+ ...deletedCategoryImageTargets,
288
288
  ],
289
289
  }
290
290
  },
@@ -304,10 +304,10 @@ export async function checkFiles(options, runtime = {}) {
304
304
  orphanedContentFiles = orphanedContentFiles.filter(
305
305
  (item) => !deletedTargets.has(item),
306
306
  )
307
- orphanedMediaThumbnails = orphanedMediaThumbnails.filter(
307
+ orphanedMediaImages = orphanedMediaImages.filter(
308
308
  (item) => !deletedTargets.has(item),
309
309
  )
310
- orphanedCategoryThumbnails = orphanedCategoryThumbnails.filter(
310
+ orphanedCategoryImages = orphanedCategoryImages.filter(
311
311
  (item) => !deletedTargets.has(item),
312
312
  )
313
313
  }
@@ -318,8 +318,8 @@ export async function checkFiles(options, runtime = {}) {
318
318
  missingContentFiles.length > 0 ||
319
319
  wrongTypeR2ContentFiles.length > 0 ||
320
320
  orphanedContentFiles.length > 0 ||
321
- orphanedMediaThumbnails.length > 0 ||
322
- orphanedCategoryThumbnails.length > 0
321
+ orphanedMediaImages.length > 0 ||
322
+ orphanedCategoryImages.length > 0
323
323
 
324
324
  for (const warning of warnings) {
325
325
  log.warn(warning)
@@ -344,8 +344,8 @@ export async function checkFiles(options, runtime = {}) {
344
344
  formatContentFilePath(filePath, options),
345
345
  ),
346
346
  )
347
- printSection("Orphaned media thumbnails", orphanedMediaThumbnails)
348
- printSection("Orphaned category thumbnails", orphanedCategoryThumbnails)
347
+ printSection("Orphaned media images", orphanedMediaImages)
348
+ printSection("Orphaned category images", orphanedCategoryImages)
349
349
  printSection("Removed items", removedItems)
350
350
 
351
351
  outro(hasIssues ? "Issues found. 🚧" : "Cleanup complete. 🧹")
@@ -458,17 +458,16 @@ function formatContentCheckSummary(result) {
458
458
  /**
459
459
  * @param {{
460
460
  * fileCount: number
461
- * orphanedCategoryThumbnails: string[]
462
- * orphanedMediaThumbnails: string[]
461
+ * orphanedCategoryImages: string[]
462
+ * orphanedMediaImages: string[]
463
463
  * referenceCount: number
464
464
  * }} result
465
465
  */
466
- function formatThumbnailCheckSummary(result) {
467
- const orphanedThumbnails =
468
- result.orphanedMediaThumbnails.length +
469
- result.orphanedCategoryThumbnails.length
466
+ function formatImageCheckSummary(result) {
467
+ const orphanedImages =
468
+ result.orphanedMediaImages.length + result.orphanedCategoryImages.length
470
469
 
471
- return `Thumbnails: ${formatCount(result.referenceCount, "reference")}, ${formatCount(result.fileCount, "file")}; orphaned: ${orphanedThumbnails} (${result.orphanedMediaThumbnails.length} media, ${result.orphanedCategoryThumbnails.length} categories).`
470
+ return `Images: ${formatCount(result.referenceCount, "reference")}, ${formatCount(result.fileCount, "file")}; orphaned: ${orphanedImages} (${result.orphanedMediaImages.length} media, ${result.orphanedCategoryImages.length} categories).`
472
471
  }
473
472
 
474
473
  /**
@@ -502,7 +501,7 @@ function formatDeletionProgress(action, count) {
502
501
  * @param {string|undefined} rawScope
503
502
  */
504
503
  function parseScopes(rawScope) {
505
- const values = (rawScope ?? "content-files,thumbnails")
504
+ const values = (rawScope ?? "content-files,images")
506
505
  .split(",")
507
506
  .map((value) => value.trim())
508
507
  .filter(Boolean)
@@ -510,7 +509,7 @@ function parseScopes(rawScope) {
510
509
  if (values.length === 0) {
511
510
  throw new CliError(
512
511
  // intentionally kept as a CLI-facing validation error
513
- 'Expected "--scope" to include at least one of "content-files" or "thumbnails".',
512
+ 'Expected "--scope" to include at least one of "content-files" or "images".',
514
513
  )
515
514
  }
516
515
 
@@ -518,7 +517,7 @@ function parseScopes(rawScope) {
518
517
  for (const scope of scopes) {
519
518
  if (!supportedScopes.has(scope)) {
520
519
  throw new CliError(
521
- `Unsupported scope "${scope}". Allowed values: content-files, thumbnails.`,
520
+ `Unsupported scope "${scope}". Allowed values: content-files, images.`,
522
521
  )
523
522
  }
524
523
  }
@@ -540,31 +539,28 @@ async function assertLightNetSiteRoot(cwd) {
540
539
  /**
541
540
  * @param {MediaItem[]} mediaItems
542
541
  * @param {Category[]} categories
543
- * @param {FileStorage} mediaThumbnailStorage
544
- * @param {FileStorage} categoryThumbnailStorage
542
+ * @param {FileStorage} mediaImageStorage
543
+ * @param {FileStorage} categoryImageStorage
545
544
  */
546
- async function validateThumbnails(
545
+ async function validateImages(
547
546
  mediaItems,
548
547
  categories,
549
- mediaThumbnailStorage,
550
- categoryThumbnailStorage,
548
+ mediaImageStorage,
549
+ categoryImageStorage,
551
550
  ) {
552
551
  const mediaReferences = new Set(
553
552
  mediaItems
554
- .map((item) => toThumbnailPath("media", item.image))
553
+ .map((item) => toImagePath("media", item.image))
555
554
  .filter((value) => value !== undefined),
556
555
  )
557
556
  const categoryReferences = new Set(
558
557
  categories
559
- .map((item) => toThumbnailPath("categories", item.image))
558
+ .map((item) => toImagePath("categories", item.image))
560
559
  .filter((value) => value !== undefined),
561
560
  )
562
561
 
563
562
  const [initializedMediaStorage, initializedCategoryStorage] =
564
- await Promise.all([
565
- mediaThumbnailStorage.init(),
566
- categoryThumbnailStorage.init(),
567
- ])
563
+ await Promise.all([mediaImageStorage.init(), categoryImageStorage.init()])
568
564
  const [mediaFiles, categoryFiles] = await Promise.all([
569
565
  initializedMediaStorage.list(),
570
566
  initializedCategoryStorage.list(),
@@ -572,10 +568,10 @@ async function validateThumbnails(
572
568
 
573
569
  return {
574
570
  fileCount: mediaFiles.length + categoryFiles.length,
575
- orphanedMediaThumbnails: mediaFiles.filter(
571
+ orphanedMediaImages: mediaFiles.filter(
576
572
  (file) => !mediaReferences.has(file),
577
573
  ),
578
- orphanedCategoryThumbnails: categoryFiles.filter(
574
+ orphanedCategoryImages: categoryFiles.filter(
579
575
  (file) => !categoryReferences.has(file),
580
576
  ),
581
577
  referenceCount: mediaReferences.size + categoryReferences.size,
@@ -700,7 +696,7 @@ function addFileReference(references, filePath, displayPath, sourceFileName) {
700
696
  * @param {"media"|"categories"} kind
701
697
  * @param {string|undefined} image
702
698
  */
703
- function toThumbnailPath(kind, image) {
699
+ function toImagePath(kind, image) {
704
700
  if (typeof image !== "string") {
705
701
  return undefined
706
702
  }
@@ -772,5 +768,5 @@ async function defaultPromptConfirm(message) {
772
768
  if (isCancel(answer)) {
773
769
  cancelPrompt()
774
770
  }
775
- return answer
771
+ return Boolean(answer)
776
772
  }
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import { posix, resolve } from "node:path"
3
+ import { matchesGlob, posix, resolve } from "node:path"
4
4
  import { cwd as processCwd, stdin, stdout } from "node:process"
5
5
 
6
6
  import { intro, isCancel, log, outro, progress, text } from "@clack/prompts"
@@ -28,6 +28,7 @@ const requestHeaders = {
28
28
 
29
29
  /**
30
30
  * @typedef {{
31
+ * exclude?: string[]
31
32
  * timeout?: string
32
33
  * }} CheckLinksOptions
33
34
  */
@@ -49,6 +50,14 @@ const requestHeaders = {
49
50
  * }} LinkReference
50
51
  */
51
52
 
53
+ /**
54
+ * @typedef {{
55
+ * displayUrl: string
56
+ * patterns: Set<string>
57
+ * sources: Set<string>
58
+ * }} ExcludedLinkReference
59
+ */
60
+
52
61
  /**
53
62
  * @typedef {{
54
63
  * ok: boolean
@@ -73,6 +82,7 @@ export async function checkLinks(options, runtime = {}) {
73
82
  "--timeout",
74
83
  defaultTimeoutMs,
75
84
  )
85
+ const excludePatterns = parseExcludePatterns(options.exclude)
76
86
 
77
87
  intro("check-links")
78
88
 
@@ -82,17 +92,28 @@ export async function checkLinks(options, runtime = {}) {
82
92
  const mediaItems = await collections.getMediaItems()
83
93
  const siteUrl = await initSiteUrl({
84
94
  cwd,
95
+ excludePatterns,
85
96
  interactive,
86
97
  mediaItems,
87
98
  promptText,
88
99
  })
89
- const references = collectLinkReferences(mediaItems, siteUrl)
100
+ const { excludedReferences, references } = collectLinkReferences(
101
+ mediaItems,
102
+ siteUrl,
103
+ excludePatterns,
104
+ )
90
105
  log.message(
91
106
  `Checking ${references.length} unique links from ${mediaItems.length} media items.`,
92
107
  )
93
108
 
109
+ printExcludedLinks(excludedReferences)
110
+
94
111
  if (references.length === 0) {
95
- log.warn("No media content URLs found.")
112
+ log.warn(
113
+ excludedReferences.length > 0
114
+ ? "No media content URLs require checking."
115
+ : "No media content URLs found.",
116
+ )
96
117
  outro("No issues found. 🎉")
97
118
  return true
98
119
  }
@@ -179,14 +200,25 @@ async function checkReferencesWithProgress(references, options) {
179
200
  /**
180
201
  * @param {{
181
202
  * cwd: string
203
+ * excludePatterns: string[]
182
204
  * interactive: boolean
183
205
  * mediaItems: MediaItem[]
184
206
  * promptText: (message: string) => Promise<string>
185
207
  * }} args
186
208
  */
187
- async function initSiteUrl({ cwd, interactive, mediaItems, promptText }) {
209
+ async function initSiteUrl({
210
+ cwd,
211
+ excludePatterns,
212
+ interactive,
213
+ mediaItems,
214
+ promptText,
215
+ }) {
188
216
  const needsSiteUrl = mediaItems.some((item) =>
189
- item.content.some((contentItem) => isRootRelativeUrl(contentItem.url)),
217
+ item.content.some(
218
+ (contentItem) =>
219
+ isRootRelativeUrl(contentItem.url) &&
220
+ findMatchingPatterns(contentItem.url, excludePatterns).length === 0,
221
+ ),
190
222
  )
191
223
  if (!needsSiteUrl) {
192
224
  return undefined
@@ -236,13 +268,36 @@ function parseSiteUrl(config) {
236
268
  /**
237
269
  * @param {MediaItem[]} mediaItems
238
270
  * @param {string|undefined} siteUrl
271
+ * @param {string[]} excludePatterns
239
272
  */
240
- function collectLinkReferences(mediaItems, siteUrl) {
273
+ function collectLinkReferences(mediaItems, siteUrl, excludePatterns) {
241
274
  /** @type {Map<string, LinkReference>} */
242
275
  const references = new Map()
276
+ /** @type {Map<string, ExcludedLinkReference>} */
277
+ const excludedReferences = new Map()
243
278
  for (const item of mediaItems) {
244
279
  const sourceFileName = posix.basename(toPosixPath(item.path))
245
280
  for (const contentItem of item.content) {
281
+ const matchingPatterns = findMatchingPatterns(
282
+ contentItem.url,
283
+ excludePatterns,
284
+ )
285
+ if (matchingPatterns.length > 0) {
286
+ const current = excludedReferences.get(contentItem.url)
287
+ if (current) {
288
+ current.sources.add(sourceFileName)
289
+ for (const pattern of matchingPatterns) {
290
+ current.patterns.add(pattern)
291
+ }
292
+ } else {
293
+ excludedReferences.set(contentItem.url, {
294
+ displayUrl: contentItem.url,
295
+ patterns: new Set(matchingPatterns),
296
+ sources: new Set([sourceFileName]),
297
+ })
298
+ }
299
+ continue
300
+ }
246
301
  const resolvedUrl = resolveUrl(contentItem.url, siteUrl)
247
302
  const current = references.get(resolvedUrl)
248
303
  if (current) {
@@ -259,9 +314,14 @@ function collectLinkReferences(mediaItems, siteUrl) {
259
314
  }
260
315
  }
261
316
  }
262
- return [...references.values()].sort((a, b) =>
263
- a.displayUrl.localeCompare(b.displayUrl),
264
- )
317
+ return {
318
+ excludedReferences: [...excludedReferences.values()].sort((a, b) =>
319
+ a.displayUrl.localeCompare(b.displayUrl),
320
+ ),
321
+ references: [...references.values()].sort((a, b) =>
322
+ a.displayUrl.localeCompare(b.displayUrl),
323
+ ),
324
+ }
265
325
  }
266
326
 
267
327
  /**
@@ -334,6 +394,25 @@ function printProtectedLinks(links) {
334
394
  }
335
395
  }
336
396
 
397
+ /**
398
+ * @param {ExcludedLinkReference[]} links
399
+ */
400
+ function printExcludedLinks(links) {
401
+ if (links.length === 0) {
402
+ return
403
+ }
404
+
405
+ log.info(`Excluded links (${links.length})`)
406
+ for (const link of links) {
407
+ const patterns = [...link.patterns]
408
+ .toSorted()
409
+ .map((pattern) => `"${pattern}"`)
410
+ log.message(
411
+ `• ${link.displayUrl}\n Excluded by: ${patterns.join(", ")}\n${formatReferenceSources(link.sources)}`,
412
+ )
413
+ }
414
+ }
415
+
337
416
  /**
338
417
  * @param {Set<string>} sources
339
418
  */
@@ -426,6 +505,63 @@ function parsePositiveInteger(rawValue, optionName, defaultValue) {
426
505
  return value
427
506
  }
428
507
 
508
+ /**
509
+ * @param {string[]|undefined} patterns
510
+ */
511
+ function parseExcludePatterns(patterns) {
512
+ return (patterns ?? []).map((pattern) => {
513
+ if (!isValidGlobPattern(pattern)) {
514
+ throw new CliError(`Invalid "--exclude" glob pattern "${pattern}".`)
515
+ }
516
+ return pattern
517
+ })
518
+ }
519
+
520
+ /**
521
+ * @param {string} url
522
+ * @param {string[]} patterns
523
+ */
524
+ function findMatchingPatterns(url, patterns) {
525
+ return patterns.filter((pattern) => matchesGlob(url, pattern))
526
+ }
527
+
528
+ /**
529
+ * Node's glob matcher treats unclosed groups as non-matches, so validate them
530
+ * explicitly to turn common malformed patterns into actionable CLI errors.
531
+ *
532
+ * @param {string} pattern
533
+ */
534
+ function isValidGlobPattern(pattern) {
535
+ /** @type {Partial<Record<string, string>>} */
536
+ const pairs = { "[": "]", "{": "}", "(": ")" }
537
+ /** @type {string[]} */
538
+ const expectedClosers = []
539
+ let escaped = false
540
+
541
+ for (const character of pattern) {
542
+ if (escaped) {
543
+ escaped = false
544
+ continue
545
+ }
546
+ if (character === "\\") {
547
+ escaped = true
548
+ continue
549
+ }
550
+ const closer = pairs[character]
551
+ if (closer) {
552
+ expectedClosers.push(closer)
553
+ continue
554
+ }
555
+ if (character === "]" || character === "}" || character === ")") {
556
+ if (expectedClosers.pop() !== character) {
557
+ return false
558
+ }
559
+ }
560
+ }
561
+
562
+ return !escaped && expectedClosers.length === 0
563
+ }
564
+
429
565
  /**
430
566
  * @param {string} value
431
567
  * @param {string} label
@@ -3,7 +3,7 @@
3
3
  import { spawn } from "node:child_process"
4
4
  import { readFile } from "node:fs/promises"
5
5
  import { resolve } from "node:path"
6
- import { cwd } from "node:process"
6
+ import { cwd, stdin, stdout } from "node:process"
7
7
 
8
8
  import { confirm, intro, isCancel, log, outro, taskLog } from "@clack/prompts"
9
9
 
@@ -30,7 +30,14 @@ import { cancelPrompt } from "./support/prompt-cancel.js"
30
30
  * }} CheckTranslationsOptions
31
31
  */
32
32
 
33
- const lightnetCachePath = resolve(cwd(), "node_modules", ".cache", "lightnet")
33
+ /**
34
+ * @typedef {{
35
+ * cwd?: string
36
+ * isInteractive?: boolean
37
+ * promptRunBuild?: () => Promise<boolean>
38
+ * write?: (message: string) => void
39
+ * }} CheckTranslationsRuntime
40
+ */
34
41
 
35
42
  /** @type {{type:Translation["type"], title:string, action:string}[]} */
36
43
  const translationSources = [
@@ -54,18 +61,33 @@ const translationSources = [
54
61
 
55
62
  /**
56
63
  * @param {CheckTranslationsOptions} [options]
64
+ * @param {CheckTranslationsRuntime} [runtime]
57
65
  */
58
- export async function checkTranslations(options = {}) {
59
- intro("check-translations")
66
+ export async function checkTranslations(options = {}, runtime = {}) {
67
+ const lightnetCachePath = resolve(
68
+ runtime.cwd ?? cwd(),
69
+ "node_modules",
70
+ ".cache",
71
+ "lightnet",
72
+ )
73
+ const interactive =
74
+ runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
75
+ const output = createOutput(interactive, runtime.write)
76
+
77
+ output.intro("check-translations")
60
78
 
61
- const buildAvailable = await runBuild(options.build)
79
+ const buildAvailable = await runBuild(options.build, {
80
+ interactive,
81
+ output,
82
+ promptRunBuild: runtime.promptRunBuild,
83
+ })
62
84
  if (!buildAvailable) {
63
- outro("Build failed. 🚧")
85
+ output.outro("Build failed. 🚧")
64
86
  return false
65
87
  }
66
88
 
67
- const translations = await readTranslations()
68
- const languages = await readLanguages()
89
+ const translations = await readTranslations(lightnetCachePath, output)
90
+ const languages = await readLanguages(lightnetCachePath, output)
69
91
  if (!translations || !languages || translations.length === 0) {
70
92
  return false
71
93
  }
@@ -77,7 +99,7 @@ export async function checkTranslations(options = {}) {
77
99
  .filter((translation) => translation.missingLocales.length > 0)
78
100
 
79
101
  if (incompleteTranslations.length === 0) {
80
- outro("No issues found. 🎉")
102
+ output.outro("No issues found. 🎉")
81
103
  return true
82
104
  }
83
105
 
@@ -86,29 +108,42 @@ export async function checkTranslations(options = {}) {
86
108
  (translation) => translation.type,
87
109
  )
88
110
 
89
- log.error("Translation check failed")
111
+ output.error("Translation check failed")
90
112
  for (const source of translationSources) {
91
- printMissingTranslations(source, grouped[source.type])
113
+ printMissingTranslations(source, grouped[source.type], output)
92
114
  }
93
115
 
94
- outro("Issues found. 🚧")
116
+ output.outro("Issues found. 🚧")
95
117
 
96
118
  return false
97
119
  }
98
120
 
99
121
  /**
100
- *
101
122
  * @param {boolean|undefined} build
102
- * @returns
123
+ * @param {{
124
+ * interactive: boolean
125
+ * output: ReturnType<typeof createOutput>
126
+ * promptRunBuild?: () => Promise<boolean>
127
+ * }} runtime
103
128
  */
104
- async function runBuild(build) {
105
- const shouldRunBuild = build ?? (await promptRunBuild())
129
+ async function runBuild(build, { interactive, output, promptRunBuild }) {
130
+ const shouldRunBuild =
131
+ build ??
132
+ (interactive ? await (promptRunBuild ?? defaultPromptRunBuild)() : false)
106
133
  if (!shouldRunBuild) {
107
134
  return true
108
135
  }
109
- const buildLog = taskLog({
110
- title: "Running pnpm build",
111
- })
136
+ const buildLog = interactive
137
+ ? taskLog({ title: "Running pnpm build" })
138
+ : {
139
+ error: output.error,
140
+ message: output.message,
141
+ success: output.message,
142
+ }
143
+
144
+ if (!interactive) {
145
+ output.message("Running pnpm build")
146
+ }
112
147
 
113
148
  const child = spawn("pnpm", ["build"], {
114
149
  shell: process.platform === "win32",
@@ -154,7 +189,7 @@ async function runBuild(build) {
154
189
  }
155
190
  }
156
191
 
157
- async function promptRunBuild() {
192
+ async function defaultPromptRunBuild() {
158
193
  const answer = await confirm({
159
194
  message:
160
195
  "Run pnpm build now? Command requires an up-to-date dist/ directory.",
@@ -170,13 +205,14 @@ async function promptRunBuild() {
170
205
  *
171
206
  * @param {{title:string, action:string}} source
172
207
  * @param {(Translation & {missingLocales:string[]})[]|undefined} translations
208
+ * @param {ReturnType<typeof createOutput>} output
173
209
  */
174
- function printMissingTranslations(source, translations) {
210
+ function printMissingTranslations(source, translations, output) {
175
211
  if (!translations || translations.length === 0) {
176
212
  return
177
213
  }
178
214
 
179
- log.warn(source.title)
215
+ output.warn(source.title)
180
216
  translations
181
217
  .toSorted(
182
218
  (t1, t2) =>
@@ -184,10 +220,10 @@ function printMissingTranslations(source, translations) {
184
220
  t1.key.localeCompare(t2.key),
185
221
  )
186
222
  .forEach(({ key, missingLocales }) => {
187
- log.message(`• ${key} > Missing: ${missingLocales.join(", ")}`)
223
+ output.message(`• ${key} > Missing: ${missingLocales.join(", ")}`)
188
224
  })
189
225
 
190
- log.message(`Action: ${source.action}`)
226
+ output.message(`Action: ${source.action}`)
191
227
  }
192
228
 
193
229
  /**
@@ -201,9 +237,11 @@ function getMissingLocales(translation, languages) {
201
237
  }
202
238
 
203
239
  /**
240
+ * @param {string} lightnetCachePath
241
+ * @param {ReturnType<typeof createOutput>} output
204
242
  * @returns {Promise<Translation[]|undefined>}
205
243
  */
206
- async function readTranslations() {
244
+ async function readTranslations(lightnetCachePath, output) {
207
245
  try {
208
246
  const translationsText = await readFile(
209
247
  resolve(lightnetCachePath, "translations.jsonl"),
@@ -214,16 +252,18 @@ async function readTranslations() {
214
252
  .filter((line) => line.trim())
215
253
  .map((line) => JSON.parse(line))
216
254
  } catch {
217
- log.error("No translation build cache found.")
218
- log.error("Action: Run build and try lightnet check-translations again.")
255
+ output.error("No translation build cache found.")
256
+ output.error("Action: Run build and try lightnet check-translations again.")
219
257
  return undefined
220
258
  }
221
259
  }
222
260
 
223
261
  /**
262
+ * @param {string} lightnetCachePath
263
+ * @param {ReturnType<typeof createOutput>} output
224
264
  * @returns {Promise<Languages|undefined>}
225
265
  */
226
- async function readLanguages() {
266
+ async function readLanguages(lightnetCachePath, output) {
227
267
  try {
228
268
  const languagesText = await readFile(
229
269
  resolve(lightnetCachePath, "languages.json"),
@@ -231,8 +271,37 @@ async function readLanguages() {
231
271
  )
232
272
  return JSON.parse(languagesText)
233
273
  } catch {
234
- log.error("No language manifest found from the last build.")
235
- log.error("Action: Run build and try lightnet check-translations again.")
274
+ output.error("No language manifest found from the last build.")
275
+ output.error("Action: Run build and try lightnet check-translations again.")
236
276
  return undefined
237
277
  }
238
278
  }
279
+
280
+ /**
281
+ * @param {boolean} interactive
282
+ * @param {(message: string) => void | undefined} write
283
+ */
284
+ function createOutput(
285
+ interactive,
286
+ write = (message) => {
287
+ stdout.write(`${message}\n`)
288
+ },
289
+ ) {
290
+ if (interactive) {
291
+ return {
292
+ error: log.error,
293
+ intro,
294
+ message: log.message,
295
+ outro,
296
+ warn: log.warn,
297
+ }
298
+ }
299
+
300
+ return {
301
+ error: write,
302
+ intro: write,
303
+ message: write,
304
+ outro: write,
305
+ warn: write,
306
+ }
307
+ }
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
3
 
4
- import { Command } from "commander"
4
+ import { Command, Option } from "commander"
5
5
 
6
6
  import pkg from "../package.json" with { type: "json" }
7
7
  import { checkFiles } from "./check-files.js"
@@ -37,7 +37,7 @@ program
37
37
  program
38
38
  .command("check-files")
39
39
  .description(
40
- "check for missing and orphaned content files and thumbnails in a LightNet site",
40
+ "check for missing and orphaned content files and images in a LightNet site",
41
41
  )
42
42
  .option("--fix", "remove orphaned files")
43
43
  .option("--fix-without-confirm", "remove orphaned files without confirmation")
@@ -45,10 +45,7 @@ program
45
45
  "--r2",
46
46
  "validate remote content files in Cloudflare R2 instead of public/files",
47
47
  )
48
- .option(
49
- "--scope <values>",
50
- "comma-separated scopes: content-files,thumbnails",
51
- )
48
+ .option("--scope <values>", "comma-separated scopes: content-files,images")
52
49
  .action(async (options) => {
53
50
  try {
54
51
  const checkSuccessful = await checkFiles(options)
@@ -62,6 +59,14 @@ program
62
59
  .command("check-links")
63
60
  .description("check media content links in a LightNet site")
64
61
  .option("--timeout <ms>", "request timeout per link in milliseconds")
62
+ .addOption(
63
+ new Option(
64
+ "--exclude <pattern>",
65
+ "exclude media content URLs matching a glob pattern (repeatable)",
66
+ )
67
+ .default([])
68
+ .argParser((pattern, patterns) => [...patterns, pattern]),
69
+ )
65
70
  .action(async (options) => {
66
71
  try {
67
72
  const checkSuccessful = await checkLinks(options)
package/src/support/r2.js CHANGED
@@ -617,9 +617,10 @@ async function promptRequiredSecret({ interactive = true } = {}) {
617
617
  if (isCancel(value)) {
618
618
  cancelPrompt()
619
619
  }
620
- if (value.trim()) {
621
- processEnv[sessionSecretEnvName] = value
622
- return value
620
+ const secret = typeof value === "string" ? value : ""
621
+ if (secret.trim()) {
622
+ processEnv[sessionSecretEnvName] = secret
623
+ return secret
623
624
  }
624
625
  }
625
626
  }