@lightnet/cli 4.3.0 → 4.4.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 +24 -0
- package/package.json +1 -1
- package/src/check-files.js +249 -65
- package/src/check-links.js +45 -7
- package/src/check-translations.js +25 -8
- package/src/index.js +5 -3
- package/src/support/cli-config.js +1 -21
- package/src/support/filesystem.js +29 -24
- package/src/support/r2.js +148 -44
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @lightnet/cli
|
|
2
2
|
|
|
3
|
+
## 4.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Delete orphaned R2 objects from `check-files --fix --r2` in batches instead of spawning one rclone process per file.
|
|
8
|
+
|
|
9
|
+
- [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Show progress feedback while `check-files` and `check-links` perform long-running checks.
|
|
10
|
+
|
|
11
|
+
- [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Improve `check-files` performance by avoiding duplicate R2 listings and parallelizing independent filesystem work.
|
|
12
|
+
|
|
13
|
+
- [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Protect R2 objects referenced by media content links from `check-files --fix --r2` cleanup and warn that they should use upload content.
|
|
14
|
+
|
|
15
|
+
- [#431](https://github.com/LightNetDev/LightNet/pull/431) [`aa5968f`](https://github.com/LightNetDev/LightNet/commit/aa5968f26112f93121f03e7aafdfb04018186032) - Decode percent-encoded R2 public URLs when matching `check-files --r2` references against bucket objects.
|
|
16
|
+
|
|
17
|
+
## 4.4.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- [#429](https://github.com/LightNetDev/LightNet/pull/429) [`00faa68`](https://github.com/LightNetDev/LightNet/commit/00faa6806bbdb74aef665d71b813449f2a15137f) - Add `check-translations --no-build` and `check-translations --build` support and improve CLI check commands.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- [#429](https://github.com/LightNetDev/LightNet/pull/429) [`00faa68`](https://github.com/LightNetDev/LightNet/commit/00faa6806bbdb74aef665d71b813449f2a15137f) - Rename `check-files --yes` to `check-files --no-confirm` for clearer unattended deletion.
|
|
26
|
+
|
|
3
27
|
## 4.3.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/package.json
CHANGED
package/src/check-files.js
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
import { join, posix, resolve } from "node:path"
|
|
4
4
|
import { cwd as processCwd, stdin, stdout } from "node:process"
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
confirm,
|
|
8
|
+
intro,
|
|
9
|
+
isCancel,
|
|
10
|
+
log,
|
|
11
|
+
outro,
|
|
12
|
+
spinner,
|
|
13
|
+
text,
|
|
14
|
+
} from "@clack/prompts"
|
|
7
15
|
|
|
8
16
|
import { CliError } from "./support/cli-error.js"
|
|
9
17
|
import { contentCollections } from "./support/content-collections.js"
|
|
@@ -25,7 +33,7 @@ const categoryImagesDir = "src/content/categories/images"
|
|
|
25
33
|
* @typedef {{
|
|
26
34
|
* scope?: string
|
|
27
35
|
* fix?: boolean
|
|
28
|
-
*
|
|
36
|
+
* confirm?: boolean
|
|
29
37
|
* r2?: boolean
|
|
30
38
|
* }} CheckFilesOptions
|
|
31
39
|
*/
|
|
@@ -48,6 +56,8 @@ const categoryImagesDir = "src/content/categories/images"
|
|
|
48
56
|
* }} FileReference
|
|
49
57
|
*/
|
|
50
58
|
|
|
59
|
+
/** @typedef {MissingReference} WrongTypeReference */
|
|
60
|
+
|
|
51
61
|
/**
|
|
52
62
|
* @typedef {{
|
|
53
63
|
* cwd?: string
|
|
@@ -79,14 +89,15 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
79
89
|
await assertLightNetSiteRoot(cwd)
|
|
80
90
|
|
|
81
91
|
const collections = contentCollections(cwd)
|
|
82
|
-
const mediaItems = await
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
)
|
|
92
|
+
const [mediaItems, categories] = await Promise.all([
|
|
93
|
+
collections.getMediaItems(),
|
|
94
|
+
collections.getCategories(),
|
|
95
|
+
])
|
|
87
96
|
|
|
88
97
|
/** @type {MissingReference[]} */
|
|
89
98
|
let missingContentFiles = []
|
|
99
|
+
/** @type {WrongTypeReference[]} */
|
|
100
|
+
let wrongTypeR2ContentFiles = []
|
|
90
101
|
/** @type {string[]} */
|
|
91
102
|
let orphanedContentFiles = []
|
|
92
103
|
/** @type {string[]} */
|
|
@@ -104,18 +115,40 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
104
115
|
const categoryThumbnailStorage = files(cwd, { rootDir: categoryImagesDir })
|
|
105
116
|
|
|
106
117
|
if (includeContentFiles) {
|
|
118
|
+
contentFileStorage = options.r2
|
|
119
|
+
? createR2FileStorage({
|
|
120
|
+
cwd,
|
|
121
|
+
interactive,
|
|
122
|
+
promptConfirm,
|
|
123
|
+
promptText,
|
|
124
|
+
})
|
|
125
|
+
: contentFiles(cwd)
|
|
126
|
+
contentFileStorage = await contentFileStorage.init()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
log.message(
|
|
130
|
+
`Checking ${mediaItems.length} media items and ${categories.length} categories.`,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if (includeContentFiles) {
|
|
134
|
+
if (!contentFileStorage) {
|
|
135
|
+
throw new CliError("Missing file storage for content validation.")
|
|
136
|
+
}
|
|
137
|
+
const initializedContentFileStorage = contentFileStorage
|
|
107
138
|
if (options.r2) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
const r2Result = await runSpinner({
|
|
140
|
+
error: "Content file check failed.",
|
|
141
|
+
start: "Listing R2 objects and checking content file references",
|
|
142
|
+
stop: (result) => formatContentCheckSummary(result),
|
|
143
|
+
task: () =>
|
|
144
|
+
validateContentFiles({
|
|
145
|
+
includeLinkReferences: true,
|
|
146
|
+
mediaItems,
|
|
147
|
+
storage: initializedContentFileStorage,
|
|
148
|
+
}),
|
|
117
149
|
})
|
|
118
150
|
missingContentFiles = r2Result.missingReferences
|
|
151
|
+
wrongTypeR2ContentFiles = r2Result.wrongTypeReferences
|
|
119
152
|
orphanedContentFiles = r2Result.orphanedFiles
|
|
120
153
|
if (r2Result.referenceCount === 0) {
|
|
121
154
|
warnings.push(
|
|
@@ -123,10 +156,15 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
123
156
|
)
|
|
124
157
|
}
|
|
125
158
|
} else {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
159
|
+
const localContentResult = await runSpinner({
|
|
160
|
+
error: "Content file check failed.",
|
|
161
|
+
start: "Checking local content files",
|
|
162
|
+
stop: (result) => formatContentCheckSummary(result),
|
|
163
|
+
task: () =>
|
|
164
|
+
validateContentFiles({
|
|
165
|
+
mediaItems,
|
|
166
|
+
storage: initializedContentFileStorage,
|
|
167
|
+
}),
|
|
130
168
|
})
|
|
131
169
|
missingContentFiles = localContentResult.missingReferences
|
|
132
170
|
orphanedContentFiles = localContentResult.orphanedFiles
|
|
@@ -139,12 +177,19 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
139
177
|
}
|
|
140
178
|
|
|
141
179
|
if (includeThumbnails) {
|
|
142
|
-
const thumbnailsResult = await
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
180
|
+
const thumbnailsResult = await runSpinner({
|
|
181
|
+
error: "Thumbnail check failed.",
|
|
182
|
+
start: "Checking thumbnails",
|
|
183
|
+
stop: (result) =>
|
|
184
|
+
`Checked thumbnails: ${result.orphanedMediaThumbnails.length} orphaned media, ${result.orphanedCategoryThumbnails.length} orphaned categories.`,
|
|
185
|
+
task: () =>
|
|
186
|
+
validateThumbnails(
|
|
187
|
+
mediaItems,
|
|
188
|
+
categories,
|
|
189
|
+
mediaThumbnailStorage,
|
|
190
|
+
categoryThumbnailStorage,
|
|
191
|
+
),
|
|
192
|
+
})
|
|
148
193
|
orphanedMediaThumbnails = thumbnailsResult.orphanedMediaThumbnails
|
|
149
194
|
orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
|
|
150
195
|
}
|
|
@@ -185,38 +230,66 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
185
230
|
}
|
|
186
231
|
|
|
187
232
|
if (deletions.length > 0) {
|
|
188
|
-
|
|
233
|
+
const shouldSkipConfirmation = options.confirm === false
|
|
234
|
+
if (!shouldSkipConfirmation && !interactive) {
|
|
189
235
|
throw new CliError(
|
|
190
|
-
'Deletion requires confirmation. Re-run with "--fix --
|
|
236
|
+
'Deletion requires confirmation. Re-run with "--fix --no-confirm" in non-interactive environments.',
|
|
191
237
|
)
|
|
192
238
|
}
|
|
193
239
|
const shouldDelete =
|
|
194
|
-
|
|
240
|
+
shouldSkipConfirmation ||
|
|
195
241
|
(await confirmDeletion({
|
|
196
242
|
deletions,
|
|
197
243
|
promptConfirm,
|
|
198
244
|
}))
|
|
199
245
|
|
|
200
246
|
if (shouldDelete) {
|
|
201
|
-
const deletedContentTargets =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
247
|
+
const { deletedContentTargets, deletedLocalTargets } = await runSpinner(
|
|
248
|
+
{
|
|
249
|
+
error: "File deletion failed.",
|
|
250
|
+
start: formatDeletionProgress("Deleting", deletions.length),
|
|
251
|
+
stop: (result) => {
|
|
252
|
+
const deletedCount =
|
|
253
|
+
result.deletedContentTargets.length +
|
|
254
|
+
result.deletedLocalTargets.length
|
|
255
|
+
return formatDeletionProgress("Deleted", deletedCount)
|
|
256
|
+
},
|
|
257
|
+
task: async () => {
|
|
258
|
+
const [
|
|
259
|
+
deletedContentTargets,
|
|
260
|
+
deletedMediaThumbnailTargets,
|
|
261
|
+
deletedCategoryThumbnailTargets,
|
|
262
|
+
] = await Promise.all([
|
|
263
|
+
contentFileStorage
|
|
264
|
+
? contentFileStorage.delete(
|
|
265
|
+
contentDeletions.map((deletion) => deletion.target),
|
|
266
|
+
)
|
|
267
|
+
: [],
|
|
268
|
+
mediaThumbnailStorage.delete(
|
|
269
|
+
localDeletions
|
|
270
|
+
.filter((deletion) =>
|
|
271
|
+
deletion.target.startsWith(mediaImagesDir),
|
|
272
|
+
)
|
|
273
|
+
.map((deletion) => deletion.target),
|
|
274
|
+
),
|
|
275
|
+
categoryThumbnailStorage.delete(
|
|
276
|
+
localDeletions
|
|
277
|
+
.filter((deletion) =>
|
|
278
|
+
deletion.target.startsWith(categoryImagesDir),
|
|
279
|
+
)
|
|
280
|
+
.map((deletion) => deletion.target),
|
|
281
|
+
),
|
|
282
|
+
])
|
|
283
|
+
return {
|
|
284
|
+
deletedContentTargets,
|
|
285
|
+
deletedLocalTargets: [
|
|
286
|
+
...deletedMediaThumbnailTargets,
|
|
287
|
+
...deletedCategoryThumbnailTargets,
|
|
288
|
+
],
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
)
|
|
220
293
|
const deletedTargets = new Set([
|
|
221
294
|
...deletedContentTargets,
|
|
222
295
|
...deletedLocalTargets,
|
|
@@ -244,6 +317,7 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
244
317
|
const hasIssues =
|
|
245
318
|
warnings.length > 0 ||
|
|
246
319
|
missingContentFiles.length > 0 ||
|
|
320
|
+
wrongTypeR2ContentFiles.length > 0 ||
|
|
247
321
|
orphanedContentFiles.length > 0 ||
|
|
248
322
|
orphanedMediaThumbnails.length > 0 ||
|
|
249
323
|
orphanedCategoryThumbnails.length > 0
|
|
@@ -261,6 +335,10 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
261
335
|
"Missing referenced content files",
|
|
262
336
|
missingContentFiles,
|
|
263
337
|
)
|
|
338
|
+
printWrongTypeReferenceSection(
|
|
339
|
+
"R2 objects referenced as links",
|
|
340
|
+
wrongTypeR2ContentFiles,
|
|
341
|
+
)
|
|
264
342
|
printSection(
|
|
265
343
|
options.r2 ? "Orphaned R2 objects" : "Orphaned local content files",
|
|
266
344
|
orphanedContentFiles.map((filePath) =>
|
|
@@ -306,6 +384,64 @@ function printMissingReferenceSection(title, items) {
|
|
|
306
384
|
}
|
|
307
385
|
}
|
|
308
386
|
|
|
387
|
+
/**
|
|
388
|
+
* @param {string} title
|
|
389
|
+
* @param {WrongTypeReference[]} items
|
|
390
|
+
*/
|
|
391
|
+
function printWrongTypeReferenceSection(title, items) {
|
|
392
|
+
if (items.length === 0) {
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
log.warn(`${title} (${items.length})`)
|
|
396
|
+
for (const item of items) {
|
|
397
|
+
log.message(
|
|
398
|
+
`• ${item.path} should use type "upload" (referenced by ${item.sources.toSorted().join(", ")})`,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* @template T
|
|
405
|
+
* @param {{
|
|
406
|
+
* error: string
|
|
407
|
+
* start: string
|
|
408
|
+
* stop: (result: T) => string
|
|
409
|
+
* task: () => Promise<T>
|
|
410
|
+
* }} args
|
|
411
|
+
* @returns {Promise<T>}
|
|
412
|
+
*/
|
|
413
|
+
async function runSpinner({ error, start, stop, task }) {
|
|
414
|
+
const activeSpinner = spinner()
|
|
415
|
+
activeSpinner.start(start)
|
|
416
|
+
try {
|
|
417
|
+
const result = await task()
|
|
418
|
+
activeSpinner.stop(stop(result))
|
|
419
|
+
return result
|
|
420
|
+
} catch (caughtError) {
|
|
421
|
+
activeSpinner.error(error)
|
|
422
|
+
throw caughtError
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* @param {{
|
|
428
|
+
* missingReferences: MissingReference[]
|
|
429
|
+
* orphanedFiles: string[]
|
|
430
|
+
* referenceCount: number
|
|
431
|
+
* }} result
|
|
432
|
+
*/
|
|
433
|
+
function formatContentCheckSummary(result) {
|
|
434
|
+
return `Checked content files: ${result.referenceCount} references, ${result.missingReferences.length} missing, ${result.orphanedFiles.length} orphaned.`
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @param {string} action
|
|
439
|
+
* @param {number} count
|
|
440
|
+
*/
|
|
441
|
+
function formatDeletionProgress(action, count) {
|
|
442
|
+
return `${action} ${count} orphaned file${count === 1 ? "" : "s"}.`
|
|
443
|
+
}
|
|
444
|
+
|
|
309
445
|
/**
|
|
310
446
|
* @param {string|undefined} rawScope
|
|
311
447
|
*/
|
|
@@ -368,10 +504,15 @@ async function validateThumbnails(
|
|
|
368
504
|
.filter((value) => value !== undefined),
|
|
369
505
|
)
|
|
370
506
|
|
|
371
|
-
const initializedMediaStorage =
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
507
|
+
const [initializedMediaStorage, initializedCategoryStorage] =
|
|
508
|
+
await Promise.all([
|
|
509
|
+
mediaThumbnailStorage.init(),
|
|
510
|
+
categoryThumbnailStorage.init(),
|
|
511
|
+
])
|
|
512
|
+
const [mediaFiles, categoryFiles] = await Promise.all([
|
|
513
|
+
initializedMediaStorage.list(),
|
|
514
|
+
initializedCategoryStorage.list(),
|
|
515
|
+
])
|
|
375
516
|
|
|
376
517
|
return {
|
|
377
518
|
orphanedMediaThumbnails: mediaFiles.filter(
|
|
@@ -385,18 +526,28 @@ async function validateThumbnails(
|
|
|
385
526
|
|
|
386
527
|
/**
|
|
387
528
|
* @param {{
|
|
529
|
+
* includeLinkReferences?: boolean
|
|
388
530
|
* mediaItems: MediaItem[]
|
|
389
531
|
* storage: FileStorage
|
|
390
532
|
* }} args
|
|
391
533
|
*/
|
|
392
|
-
async function validateContentFiles({
|
|
534
|
+
async function validateContentFiles({
|
|
535
|
+
includeLinkReferences = false,
|
|
536
|
+
mediaItems,
|
|
537
|
+
storage,
|
|
538
|
+
}) {
|
|
393
539
|
const initializedStorage = await storage.init()
|
|
394
|
-
const references = collectContentReferences(
|
|
540
|
+
const { references, wrongTypeReferences } = collectContentReferences(
|
|
541
|
+
mediaItems,
|
|
542
|
+
initializedStorage,
|
|
543
|
+
{ includeLinkReferences },
|
|
544
|
+
)
|
|
395
545
|
if (references.size === 0) {
|
|
396
546
|
return {
|
|
397
547
|
missingReferences: /** @type {MissingReference[]} */ ([]),
|
|
398
548
|
orphanedFiles: /** @type {string[]} */ ([]),
|
|
399
549
|
referenceCount: 0,
|
|
550
|
+
wrongTypeReferences: /** @type {WrongTypeReference[]} */ ([]),
|
|
400
551
|
}
|
|
401
552
|
}
|
|
402
553
|
|
|
@@ -415,41 +566,74 @@ async function validateContentFiles({ mediaItems, storage }) {
|
|
|
415
566
|
.filter((filePath) => !references.has(filePath))
|
|
416
567
|
.sort()
|
|
417
568
|
|
|
569
|
+
const existingWrongTypeReferences = [...wrongTypeReferences.entries()]
|
|
570
|
+
.filter(([path]) => fileSet.has(path))
|
|
571
|
+
.map(([, reference]) => ({
|
|
572
|
+
path: reference.displayPath,
|
|
573
|
+
sources: [...reference.sources].toSorted(),
|
|
574
|
+
}))
|
|
575
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
576
|
+
|
|
418
577
|
return {
|
|
419
578
|
missingReferences,
|
|
420
579
|
orphanedFiles,
|
|
421
580
|
referenceCount: references.size,
|
|
581
|
+
wrongTypeReferences: existingWrongTypeReferences,
|
|
422
582
|
}
|
|
423
583
|
}
|
|
424
584
|
|
|
425
585
|
/**
|
|
426
586
|
* @param {MediaItem[]} mediaItems
|
|
427
587
|
* @param {FileStorage} storage
|
|
588
|
+
* @param {{includeLinkReferences:boolean}} options
|
|
428
589
|
*/
|
|
429
|
-
function collectContentReferences(mediaItems, storage) {
|
|
590
|
+
function collectContentReferences(mediaItems, storage, options) {
|
|
430
591
|
const references = new Map()
|
|
592
|
+
const wrongTypeReferences = new Map()
|
|
431
593
|
for (const item of mediaItems) {
|
|
432
594
|
const sourceFileName = posix.basename(toPosixPath(item.path))
|
|
433
595
|
for (const contentItem of item.content) {
|
|
434
|
-
|
|
596
|
+
const isLinkReference = contentItem.type === "link"
|
|
597
|
+
if (
|
|
598
|
+
contentItem.type !== "upload" &&
|
|
599
|
+
!(options.includeLinkReferences && isLinkReference)
|
|
600
|
+
) {
|
|
435
601
|
continue
|
|
436
602
|
}
|
|
437
603
|
const filePath = storage.toPath(contentItem.url)
|
|
438
604
|
if (!filePath) {
|
|
439
605
|
continue
|
|
440
606
|
}
|
|
441
|
-
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
607
|
+
addFileReference(references, filePath, contentItem.url, sourceFileName)
|
|
608
|
+
if (isLinkReference) {
|
|
609
|
+
addFileReference(
|
|
610
|
+
wrongTypeReferences,
|
|
611
|
+
filePath,
|
|
612
|
+
contentItem.url,
|
|
613
|
+
sourceFileName,
|
|
614
|
+
)
|
|
449
615
|
}
|
|
450
616
|
}
|
|
451
617
|
}
|
|
452
|
-
return references
|
|
618
|
+
return { references, wrongTypeReferences }
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* @param {Map<string, FileReference>} references
|
|
623
|
+
* @param {string} filePath
|
|
624
|
+
* @param {string} displayPath
|
|
625
|
+
* @param {string} sourceFileName
|
|
626
|
+
*/
|
|
627
|
+
function addFileReference(references, filePath, displayPath, sourceFileName) {
|
|
628
|
+
const current = references.get(filePath)
|
|
629
|
+
if (current) {
|
|
630
|
+
current.sources.add(sourceFileName)
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
references.set(filePath, {
|
|
634
|
+
displayPath,
|
|
635
|
+
sources: new Set([sourceFileName]),
|
|
636
|
+
})
|
|
453
637
|
}
|
|
454
638
|
|
|
455
639
|
/**
|
package/src/check-links.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { posix, resolve } from "node:path"
|
|
4
4
|
import { cwd as processCwd, stdin, stdout } from "node:process"
|
|
5
5
|
|
|
6
|
-
import { intro, isCancel, log, outro, text } from "@clack/prompts"
|
|
6
|
+
import { intro, isCancel, log, outro, progress, text } from "@clack/prompts"
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
cliConfigFileName,
|
|
@@ -96,12 +96,10 @@ export async function checkLinks(options, runtime = {}) {
|
|
|
96
96
|
return true
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
const results = await
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
checkLink(reference.resolvedUrl, { fetch: runFetch, timeoutMs }),
|
|
104
|
-
)
|
|
99
|
+
const results = await checkReferencesWithProgress(references, {
|
|
100
|
+
fetch: runFetch,
|
|
101
|
+
timeoutMs,
|
|
102
|
+
})
|
|
105
103
|
const checkedLinks = references
|
|
106
104
|
.map((reference, index) => ({ reference, result: results[index] }))
|
|
107
105
|
.sort((a, b) =>
|
|
@@ -141,6 +139,46 @@ export async function checkLinks(options, runtime = {}) {
|
|
|
141
139
|
return false
|
|
142
140
|
}
|
|
143
141
|
|
|
142
|
+
/**
|
|
143
|
+
* @param {LinkReference[]} references
|
|
144
|
+
* @param {{fetch: typeof fetch, timeoutMs: number}} options
|
|
145
|
+
*/
|
|
146
|
+
async function checkReferencesWithProgress(references, options) {
|
|
147
|
+
const linkProgress = progress({ max: references.length })
|
|
148
|
+
let completed = 0
|
|
149
|
+
linkProgress.start(`Checking links (0/${references.length})`)
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const results = await mapConcurrent(
|
|
153
|
+
references,
|
|
154
|
+
defaultConcurrency,
|
|
155
|
+
async (reference) => {
|
|
156
|
+
const result = await checkLink(reference.resolvedUrl, options)
|
|
157
|
+
completed += 1
|
|
158
|
+
linkProgress.advance(
|
|
159
|
+
1,
|
|
160
|
+
`Checking links (${completed}/${references.length})`,
|
|
161
|
+
)
|
|
162
|
+
return result
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
const protectedCount = results.filter((result) =>
|
|
166
|
+
isProtectedLink(result),
|
|
167
|
+
).length
|
|
168
|
+
const unavailableCount = results.filter(
|
|
169
|
+
(result) => !result.ok && !isProtectedLink(result),
|
|
170
|
+
).length
|
|
171
|
+
const availableCount = results.length - protectedCount - unavailableCount
|
|
172
|
+
linkProgress.stop(
|
|
173
|
+
`Checked links: ${availableCount} available, ${protectedCount} protected, ${unavailableCount} unavailable.`,
|
|
174
|
+
)
|
|
175
|
+
return results
|
|
176
|
+
} catch (error) {
|
|
177
|
+
linkProgress.error("Link check failed.")
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
144
182
|
/**
|
|
145
183
|
* @param {{
|
|
146
184
|
* cwd: string
|
|
@@ -22,6 +22,12 @@ import { confirm, intro, log, outro, taskLog } from "@clack/prompts"
|
|
|
22
22
|
* }} Languages
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{
|
|
27
|
+
* build?: boolean
|
|
28
|
+
* }} CheckTranslationsOptions
|
|
29
|
+
*/
|
|
30
|
+
|
|
25
31
|
const lightnetCachePath = resolve(cwd(), "node_modules", ".cache", "lightnet")
|
|
26
32
|
|
|
27
33
|
/** @type {{type:Translation["type"], title:string, action:string}[]} */
|
|
@@ -44,14 +50,18 @@ const translationSources = [
|
|
|
44
50
|
},
|
|
45
51
|
]
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
/**
|
|
54
|
+
* @param {CheckTranslationsOptions} [options]
|
|
55
|
+
*/
|
|
56
|
+
export async function checkTranslations(options = {}) {
|
|
48
57
|
intro("check-translations")
|
|
49
58
|
|
|
50
|
-
const buildAvailable = await runBuild()
|
|
59
|
+
const buildAvailable = await runBuild(options.build)
|
|
51
60
|
if (!buildAvailable) {
|
|
52
61
|
outro("Build failed. 🚧")
|
|
53
62
|
return false
|
|
54
63
|
}
|
|
64
|
+
|
|
55
65
|
const translations = await readTranslations()
|
|
56
66
|
const languages = await readLanguages()
|
|
57
67
|
if (!translations || !languages || translations.length === 0) {
|
|
@@ -84,12 +94,19 @@ export async function checkTranslations() {
|
|
|
84
94
|
return false
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
97
|
+
/**
|
|
98
|
+
*
|
|
99
|
+
* @param {boolean|undefined} build
|
|
100
|
+
* @returns
|
|
101
|
+
*/
|
|
102
|
+
async function runBuild(build) {
|
|
103
|
+
const shouldRunBuild =
|
|
104
|
+
build ??
|
|
105
|
+
(await confirm({
|
|
106
|
+
message:
|
|
107
|
+
"Run pnpm build now? Command requires an up-to-date dist/ directory.",
|
|
108
|
+
initialValue: false,
|
|
109
|
+
}))
|
|
93
110
|
if (!shouldRunBuild) {
|
|
94
111
|
return true
|
|
95
112
|
}
|
package/src/index.js
CHANGED
|
@@ -21,8 +21,10 @@ program
|
|
|
21
21
|
program
|
|
22
22
|
.command("check-translations")
|
|
23
23
|
.description("check if last build has been missing any translations")
|
|
24
|
-
.
|
|
25
|
-
|
|
24
|
+
.option("--build", "run pnpm build before checking translations")
|
|
25
|
+
.option("--no-build", "skip the build prompt and use the latest dist/ output")
|
|
26
|
+
.action(async (options) => {
|
|
27
|
+
const checkSuccessful = await checkTranslations(options)
|
|
26
28
|
commandExitCode = checkSuccessful ? 0 : 1
|
|
27
29
|
})
|
|
28
30
|
|
|
@@ -32,7 +34,7 @@ program
|
|
|
32
34
|
"check for missing and orphaned content files and thumbnails in a LightNet site",
|
|
33
35
|
)
|
|
34
36
|
.option("--fix", "remove orphaned files")
|
|
35
|
-
.option("--
|
|
37
|
+
.option("--no-confirm", "skip deletion confirmation when used with --fix")
|
|
36
38
|
.option(
|
|
37
39
|
"--r2",
|
|
38
40
|
"validate remote content files in Cloudflare R2 instead of public/files",
|
|
@@ -6,9 +6,7 @@ import { resolve } from "node:path"
|
|
|
6
6
|
import { CliError } from "./cli-error.js"
|
|
7
7
|
import { pathExists } from "./filesystem.js"
|
|
8
8
|
|
|
9
|
-
export const cliConfigFileName = "
|
|
10
|
-
|
|
11
|
-
const gitIgnoreEntry = `${cliConfigFileName}\n`
|
|
9
|
+
export const cliConfigFileName = "lightnet-cli.json"
|
|
12
10
|
|
|
13
11
|
/**
|
|
14
12
|
* @param {string} cwd
|
|
@@ -36,22 +34,4 @@ export async function writeCliConfig(cwd, config) {
|
|
|
36
34
|
`${JSON.stringify(config, null, 2)}\n`,
|
|
37
35
|
"utf8",
|
|
38
36
|
)
|
|
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
37
|
}
|
|
@@ -58,23 +58,24 @@ export function files(cwd, options) {
|
|
|
58
58
|
* @param {string[]} paths
|
|
59
59
|
*/
|
|
60
60
|
async delete(paths) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
61
|
+
const removals = await Promise.all(
|
|
62
|
+
paths.map(async (path) => {
|
|
63
|
+
try {
|
|
64
|
+
await unlink(
|
|
65
|
+
resolve(
|
|
66
|
+
cwd,
|
|
67
|
+
options.toAbsolutePath?.(path) ??
|
|
68
|
+
toLocalFilePath(options.rootDir, path),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
return path
|
|
72
|
+
} catch {
|
|
73
|
+
// The caller prints per-file failures based on the returned paths.
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
return removals.filter((path) => path !== undefined)
|
|
78
79
|
},
|
|
79
80
|
/**
|
|
80
81
|
* @param {string} url
|
|
@@ -161,13 +162,17 @@ async function walkFiles(dirPath) {
|
|
|
161
162
|
/** @type {string[]} */
|
|
162
163
|
const files = []
|
|
163
164
|
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
165
|
+
const nestedFiles = await Promise.all(
|
|
166
|
+
entries.map(async (entry) => {
|
|
167
|
+
const entryPath = join(dirPath, entry.name)
|
|
168
|
+
if (entry.isDirectory()) {
|
|
169
|
+
return walkFiles(entryPath)
|
|
170
|
+
}
|
|
171
|
+
return entry.isFile() ? [entryPath] : []
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
for (const item of nestedFiles) {
|
|
175
|
+
files.push(...item)
|
|
171
176
|
}
|
|
172
177
|
return files
|
|
173
178
|
}
|
package/src/support/r2.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import { execFile } from "node:child_process"
|
|
4
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
5
|
+
import { tmpdir } from "node:os"
|
|
6
|
+
import { join } from "node:path"
|
|
4
7
|
import { env as processEnv } from "node:process"
|
|
5
8
|
import { promisify } from "node:util"
|
|
6
9
|
|
|
@@ -15,6 +18,7 @@ import { CliError } from "./cli-error.js"
|
|
|
15
18
|
|
|
16
19
|
const execFileAsync = promisify(execFile)
|
|
17
20
|
const sessionSecretEnvName = "LIGHTNET_R2_SECRET_ACCESS_KEY"
|
|
21
|
+
const r2DeleteBatchSize = 1000
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* @typedef {{
|
|
@@ -117,18 +121,16 @@ export function createR2FileStorage({
|
|
|
117
121
|
const storage = {
|
|
118
122
|
async init() {
|
|
119
123
|
await getConfig()
|
|
124
|
+
await getSecretAccessKey()
|
|
120
125
|
return storage
|
|
121
126
|
},
|
|
122
127
|
async list() {
|
|
123
128
|
return runWithRefresh(async (config) => {
|
|
124
|
-
const
|
|
125
|
-
const sharedArgs = {
|
|
129
|
+
const bucketObjects = await listR2Objects({
|
|
126
130
|
cwd,
|
|
127
131
|
config,
|
|
128
|
-
secretAccessKey,
|
|
129
|
-
}
|
|
130
|
-
await assertR2Access(sharedArgs)
|
|
131
|
-
const bucketObjects = await listR2Objects(sharedArgs)
|
|
132
|
+
secretAccessKey: await getSecretAccessKey(),
|
|
133
|
+
})
|
|
132
134
|
return bucketObjects.map((item) => item.key).sort()
|
|
133
135
|
})
|
|
134
136
|
},
|
|
@@ -138,22 +140,12 @@ export function createR2FileStorage({
|
|
|
138
140
|
async delete(paths) {
|
|
139
141
|
const r2Config = await getConfig()
|
|
140
142
|
const secretAccessKey = await getSecretAccessKey()
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
cwd,
|
|
148
|
-
config: r2Config,
|
|
149
|
-
secretAccessKey,
|
|
150
|
-
})
|
|
151
|
-
removed.push(path)
|
|
152
|
-
} catch {
|
|
153
|
-
// The caller prints per-file failures based on the returned paths.
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return removed
|
|
143
|
+
return deleteR2Objects({
|
|
144
|
+
config: r2Config,
|
|
145
|
+
cwd,
|
|
146
|
+
paths,
|
|
147
|
+
secretAccessKey,
|
|
148
|
+
})
|
|
157
149
|
},
|
|
158
150
|
/**
|
|
159
151
|
* @param {string} url
|
|
@@ -167,32 +159,134 @@ export function createR2FileStorage({
|
|
|
167
159
|
return undefined
|
|
168
160
|
}
|
|
169
161
|
const path = url.slice(normalizedBase.length).replace(/^\/+/, "")
|
|
170
|
-
return path || undefined
|
|
162
|
+
return decodeR2ObjectPath(path) || undefined
|
|
171
163
|
},
|
|
172
164
|
}
|
|
173
165
|
return storage
|
|
174
166
|
}
|
|
175
167
|
|
|
168
|
+
/**
|
|
169
|
+
* @param {string} path
|
|
170
|
+
*/
|
|
171
|
+
function decodeR2ObjectPath(path) {
|
|
172
|
+
try {
|
|
173
|
+
return decodeURIComponent(path)
|
|
174
|
+
} catch {
|
|
175
|
+
return path
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
176
179
|
/**
|
|
177
180
|
* @param {{
|
|
181
|
+
* config: R2Config
|
|
178
182
|
* cwd: string
|
|
183
|
+
* paths: string[]
|
|
184
|
+
* secretAccessKey: string
|
|
185
|
+
* }} args
|
|
186
|
+
*/
|
|
187
|
+
async function deleteR2Objects({ config, cwd, paths, secretAccessKey }) {
|
|
188
|
+
/** @type {string[]} */
|
|
189
|
+
const removed = []
|
|
190
|
+
const batchablePaths = paths.filter((path) => !path.includes("\n"))
|
|
191
|
+
const singleDeletePaths = paths.filter((path) => path.includes("\n"))
|
|
192
|
+
|
|
193
|
+
for (
|
|
194
|
+
let index = 0;
|
|
195
|
+
index < batchablePaths.length;
|
|
196
|
+
index += r2DeleteBatchSize
|
|
197
|
+
) {
|
|
198
|
+
const batch = batchablePaths.slice(index, index + r2DeleteBatchSize)
|
|
199
|
+
try {
|
|
200
|
+
await deleteR2ObjectBatch({ config, cwd, paths: batch, secretAccessKey })
|
|
201
|
+
removed.push(...batch)
|
|
202
|
+
} catch {
|
|
203
|
+
removed.push(
|
|
204
|
+
...(await deleteR2ObjectsIndividually({
|
|
205
|
+
config,
|
|
206
|
+
cwd,
|
|
207
|
+
paths: batch,
|
|
208
|
+
secretAccessKey,
|
|
209
|
+
})),
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
removed.push(
|
|
215
|
+
...(await deleteR2ObjectsIndividually({
|
|
216
|
+
config,
|
|
217
|
+
cwd,
|
|
218
|
+
paths: singleDeletePaths,
|
|
219
|
+
secretAccessKey,
|
|
220
|
+
})),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return removed
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {{
|
|
179
228
|
* config: R2Config
|
|
229
|
+
* cwd: string
|
|
230
|
+
* paths: string[]
|
|
180
231
|
* secretAccessKey: string
|
|
181
232
|
* }} args
|
|
182
233
|
*/
|
|
183
|
-
async function
|
|
234
|
+
async function deleteR2ObjectBatch({ config, cwd, paths, secretAccessKey }) {
|
|
235
|
+
if (paths.length === 0) {
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const tempDir = await mkdtemp(join(tmpdir(), "lightnet-r2-delete-"))
|
|
240
|
+
const fileListPath = join(tempDir, "files.txt")
|
|
184
241
|
try {
|
|
242
|
+
await writeFile(fileListPath, `${paths.join("\n")}\n`, "utf8")
|
|
185
243
|
await runConfiguredRclone({
|
|
186
|
-
args: [
|
|
187
|
-
|
|
244
|
+
args: [
|
|
245
|
+
"delete",
|
|
246
|
+
makeR2Path(config),
|
|
247
|
+
"--files-from-raw",
|
|
248
|
+
fileListPath,
|
|
249
|
+
"--fast-list",
|
|
250
|
+
],
|
|
251
|
+
cwd,
|
|
252
|
+
config,
|
|
253
|
+
secretAccessKey,
|
|
188
254
|
})
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
255
|
+
} finally {
|
|
256
|
+
await rm(tempDir, { force: true, recursive: true })
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @param {{
|
|
262
|
+
* config: R2Config
|
|
263
|
+
* cwd: string
|
|
264
|
+
* paths: string[]
|
|
265
|
+
* secretAccessKey: string
|
|
266
|
+
* }} args
|
|
267
|
+
*/
|
|
268
|
+
async function deleteR2ObjectsIndividually({
|
|
269
|
+
config,
|
|
270
|
+
cwd,
|
|
271
|
+
paths,
|
|
272
|
+
secretAccessKey,
|
|
273
|
+
}) {
|
|
274
|
+
/** @type {string[]} */
|
|
275
|
+
const removed = []
|
|
276
|
+
for (const path of paths) {
|
|
277
|
+
try {
|
|
278
|
+
await runConfiguredRclone({
|
|
279
|
+
args: ["deletefile", makeR2Path(config, path)],
|
|
280
|
+
cwd,
|
|
281
|
+
config,
|
|
282
|
+
secretAccessKey,
|
|
283
|
+
})
|
|
284
|
+
removed.push(path)
|
|
285
|
+
} catch {
|
|
286
|
+
// The caller prints per-file failures based on the returned paths.
|
|
287
|
+
}
|
|
195
288
|
}
|
|
289
|
+
return removed
|
|
196
290
|
}
|
|
197
291
|
|
|
198
292
|
/**
|
|
@@ -203,18 +297,28 @@ async function assertR2Access(args) {
|
|
|
203
297
|
* }} args
|
|
204
298
|
*/
|
|
205
299
|
async function listR2Objects(args) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
300
|
+
let listJson
|
|
301
|
+
try {
|
|
302
|
+
const result = await runConfiguredRclone({
|
|
303
|
+
args: [
|
|
304
|
+
"lsjson",
|
|
305
|
+
makeR2Path(args.config),
|
|
306
|
+
"--recursive",
|
|
307
|
+
"--files-only",
|
|
308
|
+
"--fast-list",
|
|
309
|
+
"--no-mimetype",
|
|
310
|
+
"--no-modtime",
|
|
311
|
+
],
|
|
312
|
+
...args,
|
|
313
|
+
})
|
|
314
|
+
listJson = result.stdout
|
|
315
|
+
} catch (error) {
|
|
316
|
+
throw new CliError(
|
|
317
|
+
error instanceof Error
|
|
318
|
+
? `Unable to access R2 bucket "${args.config.bucket}": ${error.message}`
|
|
319
|
+
: `Unable to access R2 bucket "${args.config.bucket}".`,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
218
322
|
|
|
219
323
|
let parsed
|
|
220
324
|
try {
|