@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 +20 -0
- package/package.json +3 -3
- package/src/check-files.js +52 -56
- package/src/check-links.js +145 -9
- package/src/check-translations.js +99 -30
- package/src/index.js +11 -6
- package/src/support/r2.js +4 -3
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.
|
|
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.
|
|
28
|
+
"@clack/prompts": "^1.8.0",
|
|
29
29
|
"commander": "^15.0.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"vitest": "^
|
|
32
|
+
"vitest": "^5.0.0"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"node": ">=22"
|
package/src/check-files.js
CHANGED
|
@@ -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", "
|
|
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
|
|
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
|
|
105
|
+
let orphanedMediaImages = []
|
|
106
106
|
/** @type {string[]} */
|
|
107
|
-
let
|
|
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
|
|
116
|
-
const
|
|
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=
|
|
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 (
|
|
181
|
-
const
|
|
182
|
-
error: "
|
|
183
|
-
start: "Checking
|
|
184
|
-
stop: (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
|
-
|
|
186
|
+
validateImages(
|
|
187
187
|
mediaItems,
|
|
188
188
|
categories,
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
mediaImageStorage,
|
|
190
|
+
categoryImageStorage,
|
|
191
191
|
),
|
|
192
192
|
})
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
287
|
-
...
|
|
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
|
-
|
|
307
|
+
orphanedMediaImages = orphanedMediaImages.filter(
|
|
308
308
|
(item) => !deletedTargets.has(item),
|
|
309
309
|
)
|
|
310
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
|
348
|
-
printSection("Orphaned category
|
|
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
|
-
*
|
|
462
|
-
*
|
|
461
|
+
* orphanedCategoryImages: string[]
|
|
462
|
+
* orphanedMediaImages: string[]
|
|
463
463
|
* referenceCount: number
|
|
464
464
|
* }} result
|
|
465
465
|
*/
|
|
466
|
-
function
|
|
467
|
-
const
|
|
468
|
-
result.
|
|
469
|
-
result.orphanedCategoryThumbnails.length
|
|
466
|
+
function formatImageCheckSummary(result) {
|
|
467
|
+
const orphanedImages =
|
|
468
|
+
result.orphanedMediaImages.length + result.orphanedCategoryImages.length
|
|
470
469
|
|
|
471
|
-
return `
|
|
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,
|
|
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 "
|
|
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,
|
|
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}
|
|
544
|
-
* @param {FileStorage}
|
|
542
|
+
* @param {FileStorage} mediaImageStorage
|
|
543
|
+
* @param {FileStorage} categoryImageStorage
|
|
545
544
|
*/
|
|
546
|
-
async function
|
|
545
|
+
async function validateImages(
|
|
547
546
|
mediaItems,
|
|
548
547
|
categories,
|
|
549
|
-
|
|
550
|
-
|
|
548
|
+
mediaImageStorage,
|
|
549
|
+
categoryImageStorage,
|
|
551
550
|
) {
|
|
552
551
|
const mediaReferences = new Set(
|
|
553
552
|
mediaItems
|
|
554
|
-
.map((item) =>
|
|
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) =>
|
|
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
|
-
|
|
571
|
+
orphanedMediaImages: mediaFiles.filter(
|
|
576
572
|
(file) => !mediaReferences.has(file),
|
|
577
573
|
),
|
|
578
|
-
|
|
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
|
|
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
|
}
|
package/src/check-links.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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({
|
|
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(
|
|
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
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
223
|
+
output.message(`• ${key} > Missing: ${missingLocales.join(", ")}`)
|
|
188
224
|
})
|
|
189
225
|
|
|
190
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
620
|
+
const secret = typeof value === "string" ? value : ""
|
|
621
|
+
if (secret.trim()) {
|
|
622
|
+
processEnv[sessionSecretEnvName] = secret
|
|
623
|
+
return secret
|
|
623
624
|
}
|
|
624
625
|
}
|
|
625
626
|
}
|