@lightnet/cli 4.1.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @lightnet/cli
2
2
 
3
+ ## 4.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#427](https://github.com/LightNetDev/LightNet/pull/427) [`d34539d`](https://github.com/LightNetDev/LightNet/commit/d34539dc21d8e37d25cf65233ba0837675062fd7) - Add a `lightnet check-links` command that validates media content URLs without downloading linked files.
8
+
9
+ - [#427](https://github.com/LightNetDev/LightNet/pull/427) [`d34539d`](https://github.com/LightNetDev/LightNet/commit/d34539dc21d8e37d25cf65233ba0837675062fd7) - check-translations will provide option to run `pnpm build`
10
+
11
+ ### Patch Changes
12
+
13
+ - [#427](https://github.com/LightNetDev/LightNet/pull/427) [`d34539d`](https://github.com/LightNetDev/LightNet/commit/d34539dc21d8e37d25cf65233ba0837675062fd7) - Refactor check-files content storage handling.
14
+
15
+ - [#427](https://github.com/LightNetDev/LightNet/pull/427) [`d34539d`](https://github.com/LightNetDev/LightNet/commit/d34539dc21d8e37d25cf65233ba0837675062fd7) - Improve output of check-translations
16
+
17
+ ## 4.2.0
18
+
19
+ ### Minor Changes
20
+
21
+ - [#423](https://github.com/LightNetDev/LightNet/pull/423) [`30fabef`](https://github.com/LightNetDev/LightNet/commit/30fabef4897506cf4d0194b8883257de513ec452) - Add a new `lightnet check-files` command to validate orphaned and missing content files and thumbnails in LightNet site projects.
22
+
3
23
  ## 4.1.1
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # LightNet CLI
2
2
 
3
- Command-line tools for working with LightNet sites. The CLI currently focuses on translation validation, with more commands planned over time.
3
+ Command-line tools for working with LightNet sites. The CLI currently includes translation validation, file consistency checks, and media link availability checks.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install --save-dev @lightnet/cli
8
+ pnpm add --D @lightnet/cli
9
9
  ```
10
10
 
11
11
  ## LightNet CLI reference
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.1.1",
6
+ "version": "4.3.0",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
@@ -25,7 +25,8 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "dependencies": {
28
- "commander": "^15.0.0"
28
+ "commander": "^15.0.0",
29
+ "@clack/prompts": "^1.6.0"
29
30
  },
30
31
  "engines": {
31
32
  "node": ">=22"
@@ -0,0 +1,526 @@
1
+ // @ts-check
2
+
3
+ import { join, posix, resolve } from "node:path"
4
+ import { cwd as processCwd, stdin, stdout } from "node:process"
5
+
6
+ import { confirm, intro, isCancel, log, outro, text } from "@clack/prompts"
7
+
8
+ import { CliError } from "./support/cli-error.js"
9
+ import { contentCollections } from "./support/content-collections.js"
10
+ import {
11
+ contentFiles,
12
+ files,
13
+ pathExists,
14
+ toLocalContentDisplayPath,
15
+ } from "./support/filesystem.js"
16
+ import { createR2FileStorage } from "./support/r2.js"
17
+
18
+ const supportedScopes = new Set(["content-files", "thumbnails"])
19
+
20
+ const mediaDir = "src/content/media"
21
+ const mediaImagesDir = "src/content/media/images"
22
+ const categoryImagesDir = "src/content/categories/images"
23
+
24
+ /**
25
+ * @typedef {{
26
+ * scope?: string
27
+ * fix?: boolean
28
+ * yes?: boolean
29
+ * r2?: boolean
30
+ * }} CheckFilesOptions
31
+ */
32
+
33
+ /** @typedef {import("./support/content-collections.js").MediaItem} MediaItem */
34
+ /** @typedef {import("./support/content-collections.js").Category} Category */
35
+ /** @typedef {import("./support/filesystem.js").FileStorage} FileStorage */
36
+
37
+ /**
38
+ * @typedef {{
39
+ * path: string
40
+ * sources: string[]
41
+ * }} MissingReference
42
+ */
43
+
44
+ /**
45
+ * @typedef {{
46
+ * displayPath: string
47
+ * sources: Set<string>
48
+ * }} FileReference
49
+ */
50
+
51
+ /**
52
+ * @typedef {{
53
+ * cwd?: string
54
+ * isInteractive?: boolean
55
+ * promptText?: (message: string) => Promise<string>
56
+ * promptConfirm?: (message: string) => Promise<boolean>
57
+ * }} CheckFilesRuntime
58
+ */
59
+
60
+ /**
61
+ * @param {CheckFilesOptions} options
62
+ * @param {CheckFilesRuntime} [runtime]
63
+ */
64
+ export async function checkFiles(options, runtime = {}) {
65
+ const cwd = runtime.cwd ?? processCwd()
66
+ const interactive =
67
+ runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
68
+ const promptText =
69
+ runtime.promptText ?? (async (message) => defaultPromptText(message))
70
+ const promptConfirm =
71
+ runtime.promptConfirm ?? (async (message) => defaultPromptConfirm(message))
72
+
73
+ const scopes = parseScopes(options.scope)
74
+ const includeContentFiles = scopes.has("content-files")
75
+ const includeThumbnails = scopes.has("thumbnails")
76
+
77
+ intro("check-files")
78
+
79
+ await assertLightNetSiteRoot(cwd)
80
+
81
+ const collections = contentCollections(cwd)
82
+ const mediaItems = await collections.getMediaItems()
83
+ const categories = await collections.getCategories()
84
+ log.message(
85
+ `Checking ${mediaItems.length} media items and ${categories.length} categories.`,
86
+ )
87
+
88
+ /** @type {MissingReference[]} */
89
+ let missingContentFiles = []
90
+ /** @type {string[]} */
91
+ let orphanedContentFiles = []
92
+ /** @type {string[]} */
93
+ let orphanedMediaThumbnails = []
94
+ /** @type {string[]} */
95
+ let orphanedCategoryThumbnails = []
96
+ /** @type {string[]} */
97
+ let removedItems = []
98
+ /** @type {string[]} */
99
+ const warnings = []
100
+
101
+ /** @type {FileStorage|undefined} */
102
+ let contentFileStorage = undefined
103
+ const mediaThumbnailStorage = files(cwd, { rootDir: mediaImagesDir })
104
+ const categoryThumbnailStorage = files(cwd, { rootDir: categoryImagesDir })
105
+
106
+ if (includeContentFiles) {
107
+ if (options.r2) {
108
+ contentFileStorage = createR2FileStorage({
109
+ cwd,
110
+ interactive,
111
+ promptConfirm,
112
+ promptText,
113
+ })
114
+ const r2Result = await validateContentFiles({
115
+ mediaItems,
116
+ storage: contentFileStorage,
117
+ })
118
+ missingContentFiles = r2Result.missingReferences
119
+ orphanedContentFiles = r2Result.orphanedFiles
120
+ if (r2Result.referenceCount === 0) {
121
+ warnings.push(
122
+ 'No R2-backed content file references found. Use "--scope=thumbnails" or check your "--r2" setup.',
123
+ )
124
+ }
125
+ } else {
126
+ contentFileStorage = contentFiles(cwd)
127
+ const localContentResult = await validateContentFiles({
128
+ mediaItems,
129
+ storage: contentFileStorage,
130
+ })
131
+ missingContentFiles = localContentResult.missingReferences
132
+ orphanedContentFiles = localContentResult.orphanedFiles
133
+ if (localContentResult.referenceCount === 0) {
134
+ warnings.push(
135
+ 'No local "/files" content file references found. Use "--scope=thumbnails" or run with "--r2".',
136
+ )
137
+ }
138
+ }
139
+ }
140
+
141
+ if (includeThumbnails) {
142
+ const thumbnailsResult = await validateThumbnails(
143
+ mediaItems,
144
+ categories,
145
+ mediaThumbnailStorage,
146
+ categoryThumbnailStorage,
147
+ )
148
+ orphanedMediaThumbnails = thumbnailsResult.orphanedMediaThumbnails
149
+ orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
150
+ }
151
+
152
+ if (options.fix) {
153
+ /** @type {{displayPath:string, target:string}[]} */
154
+ const deletions = []
155
+ /** @type {{displayPath:string, target:string}[]} */
156
+ const contentDeletions = []
157
+ /** @type {{displayPath:string, target:string}[]} */
158
+ const localDeletions = []
159
+ for (const filePath of orphanedContentFiles) {
160
+ if (!contentFileStorage) {
161
+ throw new CliError("Missing file storage for content deletion.")
162
+ }
163
+ const deletion = {
164
+ displayPath: formatContentFilePath(filePath, options),
165
+ target: filePath,
166
+ }
167
+ contentDeletions.push(deletion)
168
+ deletions.push(deletion)
169
+ }
170
+ for (const filePath of orphanedMediaThumbnails) {
171
+ const deletion = {
172
+ displayPath: filePath,
173
+ target: filePath,
174
+ }
175
+ localDeletions.push(deletion)
176
+ deletions.push(deletion)
177
+ }
178
+ for (const filePath of orphanedCategoryThumbnails) {
179
+ const deletion = {
180
+ displayPath: filePath,
181
+ target: filePath,
182
+ }
183
+ localDeletions.push(deletion)
184
+ deletions.push(deletion)
185
+ }
186
+
187
+ if (deletions.length > 0) {
188
+ if (!options.yes && !interactive) {
189
+ throw new CliError(
190
+ 'Deletion requires confirmation. Re-run with "--fix --yes" in non-interactive environments.',
191
+ )
192
+ }
193
+ const shouldDelete =
194
+ options.yes ||
195
+ (await confirmDeletion({
196
+ deletions,
197
+ promptConfirm,
198
+ }))
199
+
200
+ if (shouldDelete) {
201
+ const deletedContentTargets = contentFileStorage
202
+ ? await contentFileStorage.delete(
203
+ contentDeletions.map((deletion) => deletion.target),
204
+ )
205
+ : []
206
+ const deletedLocalTargets = [
207
+ ...(await mediaThumbnailStorage.delete(
208
+ localDeletions
209
+ .filter((deletion) => deletion.target.startsWith(mediaImagesDir))
210
+ .map((deletion) => deletion.target),
211
+ )),
212
+ ...(await categoryThumbnailStorage.delete(
213
+ localDeletions
214
+ .filter((deletion) =>
215
+ deletion.target.startsWith(categoryImagesDir),
216
+ )
217
+ .map((deletion) => deletion.target),
218
+ )),
219
+ ]
220
+ const deletedTargets = new Set([
221
+ ...deletedContentTargets,
222
+ ...deletedLocalTargets,
223
+ ])
224
+ for (const deletion of [...contentDeletions, ...localDeletions]) {
225
+ if (deletedTargets.has(deletion.target)) {
226
+ removedItems.push(deletion.displayPath)
227
+ } else {
228
+ log.error(`Failed to delete "${deletion.displayPath}".`)
229
+ }
230
+ }
231
+ orphanedContentFiles = orphanedContentFiles.filter(
232
+ (item) => !deletedTargets.has(item),
233
+ )
234
+ orphanedMediaThumbnails = orphanedMediaThumbnails.filter(
235
+ (item) => !deletedTargets.has(item),
236
+ )
237
+ orphanedCategoryThumbnails = orphanedCategoryThumbnails.filter(
238
+ (item) => !deletedTargets.has(item),
239
+ )
240
+ }
241
+ }
242
+ }
243
+
244
+ const hasIssues =
245
+ warnings.length > 0 ||
246
+ missingContentFiles.length > 0 ||
247
+ orphanedContentFiles.length > 0 ||
248
+ orphanedMediaThumbnails.length > 0 ||
249
+ orphanedCategoryThumbnails.length > 0
250
+
251
+ if (!hasIssues && removedItems.length === 0) {
252
+ outro("No issues found. 🎉")
253
+ return true
254
+ }
255
+
256
+ for (const warning of warnings) {
257
+ log.warn(warning)
258
+ }
259
+
260
+ printMissingReferenceSection(
261
+ "Missing referenced content files",
262
+ missingContentFiles,
263
+ )
264
+ printSection(
265
+ options.r2 ? "Orphaned R2 objects" : "Orphaned local content files",
266
+ orphanedContentFiles.map((filePath) =>
267
+ formatContentFilePath(filePath, options),
268
+ ),
269
+ )
270
+ printSection("Orphaned media thumbnails", orphanedMediaThumbnails)
271
+ printSection("Orphaned category thumbnails", orphanedCategoryThumbnails)
272
+ printSection("Removed items", removedItems)
273
+
274
+ outro(hasIssues ? "Issues found. 🚧" : "Cleanup complete. 🧹")
275
+
276
+ return !hasIssues
277
+ }
278
+
279
+ /**
280
+ * @param {string} title
281
+ * @param {string[]} items
282
+ */
283
+ function printSection(title, items) {
284
+ if (items.length === 0) {
285
+ return
286
+ }
287
+ log.warn(`${title} (${items.length})`)
288
+ for (const item of items) {
289
+ log.message(`• ${item}`)
290
+ }
291
+ }
292
+
293
+ /**
294
+ * @param {string} title
295
+ * @param {MissingReference[]} items
296
+ */
297
+ function printMissingReferenceSection(title, items) {
298
+ if (items.length === 0) {
299
+ return
300
+ }
301
+ log.error(`${title} (${items.length})`)
302
+ for (const item of items) {
303
+ log.message(
304
+ `• ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
305
+ )
306
+ }
307
+ }
308
+
309
+ /**
310
+ * @param {string|undefined} rawScope
311
+ */
312
+ function parseScopes(rawScope) {
313
+ const values = (rawScope ?? "content-files,thumbnails")
314
+ .split(",")
315
+ .map((value) => value.trim())
316
+ .filter(Boolean)
317
+
318
+ if (values.length === 0) {
319
+ throw new CliError(
320
+ // intentionally kept as a CLI-facing validation error
321
+ 'Expected "--scope" to include at least one of "content-files" or "thumbnails".',
322
+ )
323
+ }
324
+
325
+ const scopes = new Set(values)
326
+ for (const scope of scopes) {
327
+ if (!supportedScopes.has(scope)) {
328
+ throw new CliError(
329
+ `Unsupported scope "${scope}". Allowed values: content-files, thumbnails.`,
330
+ )
331
+ }
332
+ }
333
+
334
+ return scopes
335
+ }
336
+
337
+ /**
338
+ * @param {string} cwd
339
+ */
340
+ async function assertLightNetSiteRoot(cwd) {
341
+ if (!(await pathExists(resolve(cwd, mediaDir)))) {
342
+ throw new CliError(
343
+ 'Expected a LightNet site root with "src/content/media". Run this command inside a LightNet site project.',
344
+ )
345
+ }
346
+ }
347
+
348
+ /**
349
+ * @param {MediaItem[]} mediaItems
350
+ * @param {Category[]} categories
351
+ * @param {FileStorage} mediaThumbnailStorage
352
+ * @param {FileStorage} categoryThumbnailStorage
353
+ */
354
+ async function validateThumbnails(
355
+ mediaItems,
356
+ categories,
357
+ mediaThumbnailStorage,
358
+ categoryThumbnailStorage,
359
+ ) {
360
+ const mediaReferences = new Set(
361
+ mediaItems
362
+ .map((item) => toThumbnailPath("media", item.image))
363
+ .filter((value) => value !== undefined),
364
+ )
365
+ const categoryReferences = new Set(
366
+ categories
367
+ .map((item) => toThumbnailPath("categories", item.image))
368
+ .filter((value) => value !== undefined),
369
+ )
370
+
371
+ const initializedMediaStorage = await mediaThumbnailStorage.init()
372
+ const initializedCategoryStorage = await categoryThumbnailStorage.init()
373
+ const mediaFiles = await initializedMediaStorage.list()
374
+ const categoryFiles = await initializedCategoryStorage.list()
375
+
376
+ return {
377
+ orphanedMediaThumbnails: mediaFiles.filter(
378
+ (file) => !mediaReferences.has(file),
379
+ ),
380
+ orphanedCategoryThumbnails: categoryFiles.filter(
381
+ (file) => !categoryReferences.has(file),
382
+ ),
383
+ }
384
+ }
385
+
386
+ /**
387
+ * @param {{
388
+ * mediaItems: MediaItem[]
389
+ * storage: FileStorage
390
+ * }} args
391
+ */
392
+ async function validateContentFiles({ mediaItems, storage }) {
393
+ const initializedStorage = await storage.init()
394
+ const references = collectContentReferences(mediaItems, initializedStorage)
395
+ if (references.size === 0) {
396
+ return {
397
+ missingReferences: /** @type {MissingReference[]} */ ([]),
398
+ orphanedFiles: /** @type {string[]} */ ([]),
399
+ referenceCount: 0,
400
+ }
401
+ }
402
+
403
+ const files = await initializedStorage.list()
404
+ const fileSet = new Set(files)
405
+
406
+ const missingReferences = [...references.entries()]
407
+ .filter(([path]) => !fileSet.has(path))
408
+ .map(([, reference]) => ({
409
+ path: reference.displayPath,
410
+ sources: [...reference.sources].toSorted(),
411
+ }))
412
+ .sort((a, b) => a.path.localeCompare(b.path))
413
+
414
+ const orphanedFiles = files
415
+ .filter((filePath) => !references.has(filePath))
416
+ .sort()
417
+
418
+ return {
419
+ missingReferences,
420
+ orphanedFiles,
421
+ referenceCount: references.size,
422
+ }
423
+ }
424
+
425
+ /**
426
+ * @param {MediaItem[]} mediaItems
427
+ * @param {FileStorage} storage
428
+ */
429
+ function collectContentReferences(mediaItems, storage) {
430
+ const references = new Map()
431
+ for (const item of mediaItems) {
432
+ const sourceFileName = posix.basename(toPosixPath(item.path))
433
+ for (const contentItem of item.content) {
434
+ if (contentItem.type !== "upload") {
435
+ continue
436
+ }
437
+ const filePath = storage.toPath(contentItem.url)
438
+ if (!filePath) {
439
+ continue
440
+ }
441
+ const current = references.get(filePath)
442
+ if (current) {
443
+ current.sources.add(sourceFileName)
444
+ } else {
445
+ references.set(filePath, {
446
+ displayPath: contentItem.url,
447
+ sources: new Set([sourceFileName]),
448
+ })
449
+ }
450
+ }
451
+ }
452
+ return references
453
+ }
454
+
455
+ /**
456
+ * @param {"media"|"categories"} kind
457
+ * @param {string|undefined} image
458
+ */
459
+ function toThumbnailPath(kind, image) {
460
+ if (typeof image !== "string") {
461
+ return undefined
462
+ }
463
+ const relativePath = image.startsWith("./images/")
464
+ ? image.slice("./images/".length)
465
+ : image.startsWith("images/")
466
+ ? image.slice("images/".length)
467
+ : undefined
468
+ if (
469
+ !relativePath ||
470
+ relativePath.startsWith("/") ||
471
+ relativePath.includes("../")
472
+ ) {
473
+ return undefined
474
+ }
475
+ return kind === "media"
476
+ ? toPosixPath(join(mediaImagesDir, relativePath))
477
+ : toPosixPath(join(categoryImagesDir, relativePath))
478
+ }
479
+
480
+ /**
481
+ * @param {string} path
482
+ * @param {CheckFilesOptions} options
483
+ */
484
+ function formatContentFilePath(path, options) {
485
+ return options.r2 ? path : toLocalContentDisplayPath(path)
486
+ }
487
+
488
+ /**
489
+ * @param {{
490
+ * deletions: {displayPath:string, target:string}[]
491
+ * promptConfirm: (message:string)=>Promise<boolean>
492
+ * }} args
493
+ */
494
+ async function confirmDeletion({ deletions, promptConfirm }) {
495
+ log.warn(`Files to remove (${deletions.length})`)
496
+ for (const deletion of deletions) {
497
+ log.message(`• ${deletion.displayPath}`)
498
+ }
499
+ return promptConfirm("Delete these files? [y/N] ")
500
+ }
501
+
502
+ /**
503
+ * @param {string} filePath
504
+ */
505
+ function toPosixPath(filePath) {
506
+ return filePath.split("\\").join("/")
507
+ }
508
+
509
+ /**
510
+ * @param {string} message
511
+ */
512
+ async function defaultPromptText(message) {
513
+ const answer = await text({ message })
514
+ return isCancel(answer) ? "" : String(answer)
515
+ }
516
+
517
+ /**
518
+ * @param {string} message
519
+ */
520
+ async function defaultPromptConfirm(message) {
521
+ const answer = await confirm({
522
+ message,
523
+ initialValue: false,
524
+ })
525
+ return isCancel(answer) ? false : answer
526
+ }