@lightnet/cli 4.2.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/check-files.js +201 -508
- package/src/check-links.js +456 -0
- package/src/check-translations.js +81 -18
- package/src/index.js +19 -0
- package/src/support/cli-config.js +57 -0
- package/src/support/content-collections.js +191 -0
- package/src/support/filesystem.js +216 -0
- package/src/support/r2.js +141 -288
package/src/check-files.js
CHANGED
|
@@ -1,33 +1,25 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
cwd as processCwd,
|
|
8
|
-
env as processEnv,
|
|
9
|
-
stdin,
|
|
10
|
-
stdout,
|
|
11
|
-
} from "node:process"
|
|
12
|
-
import { createInterface } from "node:readline/promises"
|
|
13
|
-
import { promisify } from "node:util"
|
|
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"
|
|
14
7
|
|
|
15
8
|
import { CliError } from "./support/cli-error.js"
|
|
9
|
+
import { contentCollections } from "./support/content-collections.js"
|
|
16
10
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
contentFiles,
|
|
12
|
+
files,
|
|
13
|
+
pathExists,
|
|
14
|
+
toLocalContentDisplayPath,
|
|
15
|
+
} from "./support/filesystem.js"
|
|
16
|
+
import { createR2FileStorage } from "./support/r2.js"
|
|
21
17
|
|
|
22
|
-
const execFileAsync = promisify(execFile)
|
|
23
|
-
const localJunkFileNames = new Set([".DS_Store"])
|
|
24
18
|
const supportedScopes = new Set(["content-files", "thumbnails"])
|
|
25
19
|
|
|
26
20
|
const mediaDir = "src/content/media"
|
|
27
21
|
const mediaImagesDir = "src/content/media/images"
|
|
28
|
-
const categoriesDir = "src/content/categories"
|
|
29
22
|
const categoryImagesDir = "src/content/categories/images"
|
|
30
|
-
const publicFilesDir = "public/files"
|
|
31
23
|
|
|
32
24
|
/**
|
|
33
25
|
* @typedef {{
|
|
@@ -38,44 +30,30 @@ const publicFilesDir = "public/files"
|
|
|
38
30
|
* }} CheckFilesOptions
|
|
39
31
|
*/
|
|
40
32
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
* content: Array<{type: "upload"|"link", url: string}>
|
|
45
|
-
* image?: string
|
|
46
|
-
* }} ParsedMediaItem
|
|
47
|
-
*/
|
|
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 */
|
|
48
36
|
|
|
49
37
|
/**
|
|
50
38
|
* @typedef {{
|
|
51
39
|
* path: string
|
|
52
|
-
*
|
|
53
|
-
* }}
|
|
40
|
+
* sources: string[]
|
|
41
|
+
* }} MissingReference
|
|
54
42
|
*/
|
|
55
43
|
|
|
56
44
|
/**
|
|
57
45
|
* @typedef {{
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* }}
|
|
61
|
-
*/
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* @typedef {{
|
|
65
|
-
* path: string
|
|
66
|
-
* sources: string[]
|
|
67
|
-
* }} MissingReference
|
|
46
|
+
* displayPath: string
|
|
47
|
+
* sources: Set<string>
|
|
48
|
+
* }} FileReference
|
|
68
49
|
*/
|
|
69
50
|
|
|
70
51
|
/**
|
|
71
52
|
* @typedef {{
|
|
72
53
|
* cwd?: string
|
|
73
|
-
* logger?: Logger
|
|
74
54
|
* isInteractive?: boolean
|
|
75
55
|
* promptText?: (message: string) => Promise<string>
|
|
76
56
|
* promptConfirm?: (message: string) => Promise<boolean>
|
|
77
|
-
* promptSecret?: (message: string) => Promise<string>
|
|
78
|
-
* runRclone?: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
|
|
79
57
|
* }} CheckFilesRuntime
|
|
80
58
|
*/
|
|
81
59
|
|
|
@@ -85,34 +63,27 @@ const publicFilesDir = "public/files"
|
|
|
85
63
|
*/
|
|
86
64
|
export async function checkFiles(options, runtime = {}) {
|
|
87
65
|
const cwd = runtime.cwd ?? processCwd()
|
|
88
|
-
const logger = runtime.logger ?? console
|
|
89
66
|
const interactive =
|
|
90
67
|
runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
|
|
91
68
|
const promptText =
|
|
92
|
-
runtime.promptText ??
|
|
93
|
-
(async (message) =>
|
|
94
|
-
defaultPromptText(message, { input: stdin, output: stdout }))
|
|
69
|
+
runtime.promptText ?? (async (message) => defaultPromptText(message))
|
|
95
70
|
const promptConfirm =
|
|
96
|
-
runtime.promptConfirm ??
|
|
97
|
-
(async (message) =>
|
|
98
|
-
defaultPromptConfirm(message, { input: stdin, output: stdout }))
|
|
99
|
-
const promptSecret = runtime.promptSecret
|
|
100
|
-
const runRclone =
|
|
101
|
-
runtime.runRclone ??
|
|
102
|
-
((args, commandCwd, commandEnv) =>
|
|
103
|
-
execFileAsync("rclone", args, {
|
|
104
|
-
cwd: commandCwd,
|
|
105
|
-
env: commandEnv ?? processEnv,
|
|
106
|
-
}))
|
|
71
|
+
runtime.promptConfirm ?? (async (message) => defaultPromptConfirm(message))
|
|
107
72
|
|
|
108
73
|
const scopes = parseScopes(options.scope)
|
|
109
74
|
const includeContentFiles = scopes.has("content-files")
|
|
110
75
|
const includeThumbnails = scopes.has("thumbnails")
|
|
111
76
|
|
|
77
|
+
intro("check-files")
|
|
78
|
+
|
|
112
79
|
await assertLightNetSiteRoot(cwd)
|
|
113
80
|
|
|
114
|
-
const
|
|
115
|
-
const
|
|
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
|
+
)
|
|
116
87
|
|
|
117
88
|
/** @type {MissingReference[]} */
|
|
118
89
|
let missingContentFiles = []
|
|
@@ -127,40 +98,36 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
127
98
|
/** @type {string[]} */
|
|
128
99
|
const warnings = []
|
|
129
100
|
|
|
130
|
-
|
|
101
|
+
/** @type {FileStorage|undefined} */
|
|
102
|
+
let contentFileStorage = undefined
|
|
103
|
+
const mediaThumbnailStorage = files(cwd, { rootDir: mediaImagesDir })
|
|
104
|
+
const categoryThumbnailStorage = files(cwd, { rootDir: categoryImagesDir })
|
|
131
105
|
|
|
132
106
|
if (includeContentFiles) {
|
|
133
107
|
if (options.r2) {
|
|
134
|
-
|
|
135
|
-
cwd,
|
|
136
|
-
interactive,
|
|
137
|
-
logger,
|
|
138
|
-
promptText,
|
|
139
|
-
})
|
|
140
|
-
const r2Result = await validateR2ReferencesWithRefresh({
|
|
108
|
+
contentFileStorage = createR2FileStorage({
|
|
141
109
|
cwd,
|
|
142
110
|
interactive,
|
|
143
|
-
logger,
|
|
144
111
|
promptConfirm,
|
|
145
112
|
promptText,
|
|
146
|
-
promptSecret,
|
|
147
|
-
config: r2Config,
|
|
148
|
-
references: collectR2ContentReferences(mediaItems, r2Config.publicUrl),
|
|
149
|
-
runRclone,
|
|
150
113
|
})
|
|
151
|
-
|
|
114
|
+
const r2Result = await validateContentFiles({
|
|
115
|
+
mediaItems,
|
|
116
|
+
storage: contentFileStorage,
|
|
117
|
+
})
|
|
152
118
|
missingContentFiles = r2Result.missingReferences
|
|
153
|
-
orphanedContentFiles = r2Result.
|
|
119
|
+
orphanedContentFiles = r2Result.orphanedFiles
|
|
154
120
|
if (r2Result.referenceCount === 0) {
|
|
155
121
|
warnings.push(
|
|
156
122
|
'No R2-backed content file references found. Use "--scope=thumbnails" or check your "--r2" setup.',
|
|
157
123
|
)
|
|
158
124
|
}
|
|
159
125
|
} else {
|
|
160
|
-
|
|
161
|
-
|
|
126
|
+
contentFileStorage = contentFiles(cwd)
|
|
127
|
+
const localContentResult = await validateContentFiles({
|
|
162
128
|
mediaItems,
|
|
163
|
-
|
|
129
|
+
storage: contentFileStorage,
|
|
130
|
+
})
|
|
164
131
|
missingContentFiles = localContentResult.missingReferences
|
|
165
132
|
orphanedContentFiles = localContentResult.orphanedFiles
|
|
166
133
|
if (localContentResult.referenceCount === 0) {
|
|
@@ -173,45 +140,48 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
173
140
|
|
|
174
141
|
if (includeThumbnails) {
|
|
175
142
|
const thumbnailsResult = await validateThumbnails(
|
|
176
|
-
cwd,
|
|
177
143
|
mediaItems,
|
|
178
144
|
categories,
|
|
145
|
+
mediaThumbnailStorage,
|
|
146
|
+
categoryThumbnailStorage,
|
|
179
147
|
)
|
|
180
148
|
orphanedMediaThumbnails = thumbnailsResult.orphanedMediaThumbnails
|
|
181
149
|
orphanedCategoryThumbnails = thumbnailsResult.orphanedCategoryThumbnails
|
|
182
150
|
}
|
|
183
151
|
|
|
184
152
|
if (options.fix) {
|
|
185
|
-
/** @type {{displayPath:string,
|
|
153
|
+
/** @type {{displayPath:string, target:string}[]} */
|
|
186
154
|
const deletions = []
|
|
155
|
+
/** @type {{displayPath:string, target:string}[]} */
|
|
156
|
+
const contentDeletions = []
|
|
157
|
+
/** @type {{displayPath:string, target:string}[]} */
|
|
158
|
+
const localDeletions = []
|
|
187
159
|
for (const filePath of orphanedContentFiles) {
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
displayPath: filePath,
|
|
191
|
-
kind: "r2-object",
|
|
192
|
-
target: filePath,
|
|
193
|
-
})
|
|
194
|
-
} else {
|
|
195
|
-
deletions.push({
|
|
196
|
-
displayPath: filePath,
|
|
197
|
-
kind: "local-file",
|
|
198
|
-
target: resolve(cwd, filePath),
|
|
199
|
-
})
|
|
160
|
+
if (!contentFileStorage) {
|
|
161
|
+
throw new CliError("Missing file storage for content deletion.")
|
|
200
162
|
}
|
|
163
|
+
const deletion = {
|
|
164
|
+
displayPath: formatContentFilePath(filePath, options),
|
|
165
|
+
target: filePath,
|
|
166
|
+
}
|
|
167
|
+
contentDeletions.push(deletion)
|
|
168
|
+
deletions.push(deletion)
|
|
201
169
|
}
|
|
202
170
|
for (const filePath of orphanedMediaThumbnails) {
|
|
203
|
-
|
|
171
|
+
const deletion = {
|
|
204
172
|
displayPath: filePath,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
173
|
+
target: filePath,
|
|
174
|
+
}
|
|
175
|
+
localDeletions.push(deletion)
|
|
176
|
+
deletions.push(deletion)
|
|
208
177
|
}
|
|
209
178
|
for (const filePath of orphanedCategoryThumbnails) {
|
|
210
|
-
|
|
179
|
+
const deletion = {
|
|
211
180
|
displayPath: filePath,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
181
|
+
target: filePath,
|
|
182
|
+
}
|
|
183
|
+
localDeletions.push(deletion)
|
|
184
|
+
deletions.push(deletion)
|
|
215
185
|
}
|
|
216
186
|
|
|
217
187
|
if (deletions.length > 0) {
|
|
@@ -224,28 +194,48 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
224
194
|
options.yes ||
|
|
225
195
|
(await confirmDeletion({
|
|
226
196
|
deletions,
|
|
227
|
-
logger,
|
|
228
197
|
promptConfirm,
|
|
229
198
|
}))
|
|
230
199
|
|
|
231
200
|
if (shouldDelete) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
}
|
|
241
231
|
orphanedContentFiles = orphanedContentFiles.filter(
|
|
242
|
-
(item) => !
|
|
232
|
+
(item) => !deletedTargets.has(item),
|
|
243
233
|
)
|
|
244
234
|
orphanedMediaThumbnails = orphanedMediaThumbnails.filter(
|
|
245
|
-
(item) => !
|
|
235
|
+
(item) => !deletedTargets.has(item),
|
|
246
236
|
)
|
|
247
237
|
orphanedCategoryThumbnails = orphanedCategoryThumbnails.filter(
|
|
248
|
-
(item) => !
|
|
238
|
+
(item) => !deletedTargets.has(item),
|
|
249
239
|
)
|
|
250
240
|
}
|
|
251
241
|
}
|
|
@@ -259,64 +249,59 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
259
249
|
orphanedCategoryThumbnails.length > 0
|
|
260
250
|
|
|
261
251
|
if (!hasIssues && removedItems.length === 0) {
|
|
252
|
+
outro("No issues found. 🎉")
|
|
262
253
|
return true
|
|
263
254
|
}
|
|
264
255
|
|
|
265
256
|
for (const warning of warnings) {
|
|
266
|
-
|
|
257
|
+
log.warn(warning)
|
|
267
258
|
}
|
|
268
259
|
|
|
269
260
|
printMissingReferenceSection(
|
|
270
|
-
logger,
|
|
271
261
|
"Missing referenced content files",
|
|
272
262
|
missingContentFiles,
|
|
273
263
|
)
|
|
274
264
|
printSection(
|
|
275
|
-
logger,
|
|
276
265
|
options.r2 ? "Orphaned R2 objects" : "Orphaned local content files",
|
|
277
|
-
orphanedContentFiles
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
printSection(
|
|
281
|
-
logger,
|
|
282
|
-
"Orphaned category thumbnails",
|
|
283
|
-
orphanedCategoryThumbnails,
|
|
266
|
+
orphanedContentFiles.map((filePath) =>
|
|
267
|
+
formatContentFilePath(filePath, options),
|
|
268
|
+
),
|
|
284
269
|
)
|
|
285
|
-
printSection(
|
|
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. 🧹")
|
|
286
275
|
|
|
287
276
|
return !hasIssues
|
|
288
277
|
}
|
|
289
278
|
|
|
290
279
|
/**
|
|
291
|
-
* @param {Logger} logger
|
|
292
280
|
* @param {string} title
|
|
293
281
|
* @param {string[]} items
|
|
294
282
|
*/
|
|
295
|
-
function printSection(
|
|
283
|
+
function printSection(title, items) {
|
|
296
284
|
if (items.length === 0) {
|
|
297
285
|
return
|
|
298
286
|
}
|
|
299
|
-
|
|
300
|
-
logger.error(`${title} (${items.length})`)
|
|
287
|
+
log.warn(`${title} (${items.length})`)
|
|
301
288
|
for (const item of items) {
|
|
302
|
-
|
|
289
|
+
log.message(`• ${item}`)
|
|
303
290
|
}
|
|
304
291
|
}
|
|
305
292
|
|
|
306
293
|
/**
|
|
307
|
-
* @param {Logger} logger
|
|
308
294
|
* @param {string} title
|
|
309
295
|
* @param {MissingReference[]} items
|
|
310
296
|
*/
|
|
311
|
-
function printMissingReferenceSection(
|
|
297
|
+
function printMissingReferenceSection(title, items) {
|
|
312
298
|
if (items.length === 0) {
|
|
313
299
|
return
|
|
314
300
|
}
|
|
315
|
-
|
|
316
|
-
logger.error(`${title} (${items.length})`)
|
|
301
|
+
log.error(`${title} (${items.length})`)
|
|
317
302
|
for (const item of items) {
|
|
318
|
-
|
|
319
|
-
|
|
303
|
+
log.message(
|
|
304
|
+
`• ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
|
|
320
305
|
)
|
|
321
306
|
}
|
|
322
307
|
}
|
|
@@ -361,106 +346,17 @@ async function assertLightNetSiteRoot(cwd) {
|
|
|
361
346
|
}
|
|
362
347
|
|
|
363
348
|
/**
|
|
364
|
-
* @param {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
/** @type {ParsedMediaItem[]} */
|
|
369
|
-
const items = []
|
|
370
|
-
for (const filePath of files) {
|
|
371
|
-
items.push(await readMediaItem(filePath))
|
|
372
|
-
}
|
|
373
|
-
return items
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* @param {string} cwd
|
|
378
|
-
*/
|
|
379
|
-
async function readCategories(cwd) {
|
|
380
|
-
const absoluteDir = resolve(cwd, categoriesDir)
|
|
381
|
-
if (!(await pathExists(absoluteDir))) {
|
|
382
|
-
return []
|
|
383
|
-
}
|
|
384
|
-
const files = await listJsonFiles(absoluteDir)
|
|
385
|
-
/** @type {ParsedCategory[]} */
|
|
386
|
-
const items = []
|
|
387
|
-
for (const filePath of files) {
|
|
388
|
-
items.push(await readCategory(filePath))
|
|
389
|
-
}
|
|
390
|
-
return items
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* @param {string} filePath
|
|
395
|
-
* @returns {Promise<ParsedMediaItem>}
|
|
396
|
-
*/
|
|
397
|
-
async function readMediaItem(filePath) {
|
|
398
|
-
const value = await readJsonFile(filePath)
|
|
399
|
-
if (!isPlainObject(value)) {
|
|
400
|
-
throw new CliError(
|
|
401
|
-
`Invalid media item in "${filePath}": expected an object.`,
|
|
402
|
-
)
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const image = readOptionalImageReference(value.image, filePath)
|
|
406
|
-
const content = readContentArray(value.content, filePath)
|
|
407
|
-
|
|
408
|
-
return {
|
|
409
|
-
path: filePath,
|
|
410
|
-
content,
|
|
411
|
-
image,
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* @param {string} filePath
|
|
417
|
-
* @returns {Promise<ParsedCategory>}
|
|
418
|
-
*/
|
|
419
|
-
async function readCategory(filePath) {
|
|
420
|
-
const value = await readJsonFile(filePath)
|
|
421
|
-
if (!isPlainObject(value)) {
|
|
422
|
-
throw new CliError(`Invalid category in "${filePath}": expected an object.`)
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
return {
|
|
426
|
-
path: filePath,
|
|
427
|
-
image: readOptionalImageReference(value.image, filePath),
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* @param {string} cwd
|
|
433
|
-
* @param {ParsedMediaItem[]} mediaItems
|
|
434
|
-
*/
|
|
435
|
-
async function validateLocalContentFiles(cwd, mediaItems) {
|
|
436
|
-
const references = collectLocalContentReferences(mediaItems)
|
|
437
|
-
const files = await collectLocalContentFiles(cwd)
|
|
438
|
-
|
|
439
|
-
const missingReferences = [...references.entries()]
|
|
440
|
-
.filter(([path]) => !files.urlSet.has(path))
|
|
441
|
-
.map(([path, sources]) => ({
|
|
442
|
-
path,
|
|
443
|
-
sources: [...sources].toSorted(),
|
|
444
|
-
}))
|
|
445
|
-
.sort((a, b) => a.path.localeCompare(b.path))
|
|
446
|
-
const orphanedFiles = [...files.pathByUrl.entries()]
|
|
447
|
-
.filter(([url]) => !references.has(url))
|
|
448
|
-
.map(([, filePath]) => filePath)
|
|
449
|
-
.sort()
|
|
450
|
-
|
|
451
|
-
return {
|
|
452
|
-
missingReferences,
|
|
453
|
-
orphanedFiles,
|
|
454
|
-
referenceCount: references.size,
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* @param {string} cwd
|
|
460
|
-
* @param {ParsedMediaItem[]} mediaItems
|
|
461
|
-
* @param {ParsedCategory[]} categories
|
|
349
|
+
* @param {MediaItem[]} mediaItems
|
|
350
|
+
* @param {Category[]} categories
|
|
351
|
+
* @param {FileStorage} mediaThumbnailStorage
|
|
352
|
+
* @param {FileStorage} categoryThumbnailStorage
|
|
462
353
|
*/
|
|
463
|
-
async function validateThumbnails(
|
|
354
|
+
async function validateThumbnails(
|
|
355
|
+
mediaItems,
|
|
356
|
+
categories,
|
|
357
|
+
mediaThumbnailStorage,
|
|
358
|
+
categoryThumbnailStorage,
|
|
359
|
+
) {
|
|
464
360
|
const mediaReferences = new Set(
|
|
465
361
|
mediaItems
|
|
466
362
|
.map((item) => toThumbnailPath("media", item.image))
|
|
@@ -472,17 +368,10 @@ async function validateThumbnails(cwd, mediaItems, categories) {
|
|
|
472
368
|
.filter((value) => value !== undefined),
|
|
473
369
|
)
|
|
474
370
|
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const categoryFiles = await collectLocalFiles(
|
|
480
|
-
resolve(cwd, categoryImagesDir),
|
|
481
|
-
{
|
|
482
|
-
includeRootPrefix: categoryImagesDir,
|
|
483
|
-
ignoreJunk: true,
|
|
484
|
-
},
|
|
485
|
-
)
|
|
371
|
+
const initializedMediaStorage = await mediaThumbnailStorage.init()
|
|
372
|
+
const initializedCategoryStorage = await categoryThumbnailStorage.init()
|
|
373
|
+
const mediaFiles = await initializedMediaStorage.list()
|
|
374
|
+
const categoryFiles = await initializedCategoryStorage.list()
|
|
486
375
|
|
|
487
376
|
return {
|
|
488
377
|
orphanedMediaThumbnails: mediaFiles.filter(
|
|
@@ -495,51 +384,66 @@ async function validateThumbnails(cwd, mediaItems, categories) {
|
|
|
495
384
|
}
|
|
496
385
|
|
|
497
386
|
/**
|
|
498
|
-
* @param {
|
|
387
|
+
* @param {{
|
|
388
|
+
* mediaItems: MediaItem[]
|
|
389
|
+
* storage: FileStorage
|
|
390
|
+
* }} args
|
|
499
391
|
*/
|
|
500
|
-
function
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
) {
|
|
509
|
-
const sources = references.get(contentItem.url) ?? new Set()
|
|
510
|
-
sources.add(sourceFileName)
|
|
511
|
-
references.set(contentItem.url, sources)
|
|
512
|
-
}
|
|
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,
|
|
513
400
|
}
|
|
514
401
|
}
|
|
515
|
-
|
|
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
|
+
}
|
|
516
423
|
}
|
|
517
424
|
|
|
518
425
|
/**
|
|
519
|
-
* @param {
|
|
520
|
-
* @param {
|
|
426
|
+
* @param {MediaItem[]} mediaItems
|
|
427
|
+
* @param {FileStorage} storage
|
|
521
428
|
*/
|
|
522
|
-
function
|
|
429
|
+
function collectContentReferences(mediaItems, storage) {
|
|
523
430
|
const references = new Map()
|
|
524
|
-
const normalizedBase = publicUrl.replace(/\/+$/, "")
|
|
525
431
|
for (const item of mediaItems) {
|
|
526
432
|
const sourceFileName = posix.basename(toPosixPath(item.path))
|
|
527
433
|
for (const contentItem of item.content) {
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
)
|
|
434
|
+
if (contentItem.type !== "upload") {
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
const filePath = storage.toPath(contentItem.url)
|
|
438
|
+
if (!filePath) {
|
|
532
439
|
continue
|
|
533
440
|
}
|
|
534
|
-
const
|
|
535
|
-
.slice(normalizedBase.length)
|
|
536
|
-
.replace(/^\/+/, "")
|
|
537
|
-
const current = references.get(contentItem.url)
|
|
441
|
+
const current = references.get(filePath)
|
|
538
442
|
if (current) {
|
|
539
443
|
current.sources.add(sourceFileName)
|
|
540
444
|
} else {
|
|
541
|
-
references.set(
|
|
542
|
-
|
|
445
|
+
references.set(filePath, {
|
|
446
|
+
displayPath: contentItem.url,
|
|
543
447
|
sources: new Set([sourceFileName]),
|
|
544
448
|
})
|
|
545
449
|
}
|
|
@@ -548,58 +452,19 @@ function collectR2ContentReferences(mediaItems, publicUrl) {
|
|
|
548
452
|
return references
|
|
549
453
|
}
|
|
550
454
|
|
|
551
|
-
/**
|
|
552
|
-
* @param {string} cwd
|
|
553
|
-
*/
|
|
554
|
-
async function collectLocalContentFiles(cwd) {
|
|
555
|
-
const files = await collectLocalFiles(resolve(cwd, publicFilesDir), {
|
|
556
|
-
includeRootPrefix: publicFilesDir,
|
|
557
|
-
ignoreJunk: true,
|
|
558
|
-
})
|
|
559
|
-
const pathByUrl = new Map(
|
|
560
|
-
files.map((filePath) => [
|
|
561
|
-
`/${toPosixPath(relative(resolve(cwd, "public"), resolve(cwd, filePath)))}`,
|
|
562
|
-
filePath,
|
|
563
|
-
]),
|
|
564
|
-
)
|
|
565
|
-
return {
|
|
566
|
-
pathByUrl,
|
|
567
|
-
urlSet: new Set(pathByUrl.keys()),
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* @param {string} absoluteDir
|
|
573
|
-
* @param {{includeRootPrefix:string, ignoreJunk:boolean}} options
|
|
574
|
-
*/
|
|
575
|
-
async function collectLocalFiles(absoluteDir, options) {
|
|
576
|
-
if (!(await pathExists(absoluteDir))) {
|
|
577
|
-
return []
|
|
578
|
-
}
|
|
579
|
-
const files = await walkFiles(absoluteDir)
|
|
580
|
-
return files
|
|
581
|
-
.filter((filePath) =>
|
|
582
|
-
options.ignoreJunk
|
|
583
|
-
? !localJunkFileNames.has(posix.basename(filePath))
|
|
584
|
-
: true,
|
|
585
|
-
)
|
|
586
|
-
.map((filePath) =>
|
|
587
|
-
toPosixPath(
|
|
588
|
-
join(options.includeRootPrefix, relative(absoluteDir, filePath)),
|
|
589
|
-
),
|
|
590
|
-
)
|
|
591
|
-
.sort()
|
|
592
|
-
}
|
|
593
|
-
|
|
594
455
|
/**
|
|
595
456
|
* @param {"media"|"categories"} kind
|
|
596
457
|
* @param {string|undefined} image
|
|
597
458
|
*/
|
|
598
459
|
function toThumbnailPath(kind, image) {
|
|
599
|
-
if (typeof image !== "string"
|
|
460
|
+
if (typeof image !== "string") {
|
|
600
461
|
return undefined
|
|
601
462
|
}
|
|
602
|
-
const relativePath = image.
|
|
463
|
+
const relativePath = image.startsWith("./images/")
|
|
464
|
+
? image.slice("./images/".length)
|
|
465
|
+
: image.startsWith("images/")
|
|
466
|
+
? image.slice("images/".length)
|
|
467
|
+
: undefined
|
|
603
468
|
if (
|
|
604
469
|
!relativePath ||
|
|
605
470
|
relativePath.startsWith("/") ||
|
|
@@ -613,194 +478,25 @@ function toThumbnailPath(kind, image) {
|
|
|
613
478
|
}
|
|
614
479
|
|
|
615
480
|
/**
|
|
616
|
-
* @param {
|
|
617
|
-
*
|
|
618
|
-
* logger: Logger
|
|
619
|
-
* promptConfirm: (message:string)=>Promise<boolean>
|
|
620
|
-
* }} args
|
|
481
|
+
* @param {string} path
|
|
482
|
+
* @param {CheckFilesOptions} options
|
|
621
483
|
*/
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
logger.error(`Files to remove (${deletions.length})`)
|
|
625
|
-
for (const deletion of deletions) {
|
|
626
|
-
logger.error(`- ${deletion.displayPath}`)
|
|
627
|
-
}
|
|
628
|
-
return promptConfirm("Delete these files? [y/N] ")
|
|
484
|
+
function formatContentFilePath(path, options) {
|
|
485
|
+
return options.r2 ? path : toLocalContentDisplayPath(path)
|
|
629
486
|
}
|
|
630
487
|
|
|
631
488
|
/**
|
|
632
489
|
* @param {{
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
* r2Config: import("./support/r2.js").R2Config|undefined
|
|
636
|
-
* deletions: {displayPath:string, kind:"local-file"|"r2-object", target:string}[]
|
|
637
|
-
* options: CheckFilesOptions
|
|
638
|
-
* promptSecret?: (message: string) => Promise<string>
|
|
639
|
-
* runRclone: (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<{stdout:string, stderr:string}>
|
|
490
|
+
* deletions: {displayPath:string, target:string}[]
|
|
491
|
+
* promptConfirm: (message:string)=>Promise<boolean>
|
|
640
492
|
* }} args
|
|
641
493
|
*/
|
|
642
|
-
async function
|
|
643
|
-
|
|
644
|
-
logger,
|
|
645
|
-
r2Config,
|
|
646
|
-
deletions,
|
|
647
|
-
options,
|
|
648
|
-
promptSecret,
|
|
649
|
-
runRclone,
|
|
650
|
-
}) {
|
|
651
|
-
/** @type {string[]} */
|
|
652
|
-
const removed = []
|
|
653
|
-
const r2ObjectKeys = []
|
|
494
|
+
async function confirmDeletion({ deletions, promptConfirm }) {
|
|
495
|
+
log.warn(`Files to remove (${deletions.length})`)
|
|
654
496
|
for (const deletion of deletions) {
|
|
655
|
-
|
|
656
|
-
if (deletion.kind === "local-file") {
|
|
657
|
-
await unlink(deletion.target)
|
|
658
|
-
removed.push(deletion.displayPath)
|
|
659
|
-
} else {
|
|
660
|
-
if (!options.r2 || !r2Config) {
|
|
661
|
-
throw new CliError("Missing R2 config for remote deletion.")
|
|
662
|
-
}
|
|
663
|
-
r2ObjectKeys.push(deletion.target)
|
|
664
|
-
}
|
|
665
|
-
} catch (error) {
|
|
666
|
-
logger.error(
|
|
667
|
-
error instanceof Error
|
|
668
|
-
? `Failed to delete "${deletion.displayPath}": ${error.message}`
|
|
669
|
-
: `Failed to delete "${deletion.displayPath}".`,
|
|
670
|
-
)
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
if (r2ObjectKeys.length > 0) {
|
|
674
|
-
if (!r2Config) {
|
|
675
|
-
throw new CliError("Missing R2 config for remote deletion.")
|
|
676
|
-
}
|
|
677
|
-
const removedRemoteItems = await deleteR2Objects({
|
|
678
|
-
cwd,
|
|
679
|
-
config: r2Config,
|
|
680
|
-
objectKeys: r2ObjectKeys,
|
|
681
|
-
promptSecret,
|
|
682
|
-
runRclone,
|
|
683
|
-
})
|
|
684
|
-
removed.push(...removedRemoteItems)
|
|
685
|
-
const failedRemoteItems = r2ObjectKeys.filter(
|
|
686
|
-
(item) => !removedRemoteItems.includes(item),
|
|
687
|
-
)
|
|
688
|
-
for (const item of failedRemoteItems) {
|
|
689
|
-
logger.error(`Failed to delete "${item}".`)
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
return removed
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/**
|
|
696
|
-
* @param {string} filePath
|
|
697
|
-
*/
|
|
698
|
-
async function readJsonFile(filePath) {
|
|
699
|
-
const text = await readFile(filePath, "utf8")
|
|
700
|
-
try {
|
|
701
|
-
return JSON.parse(text)
|
|
702
|
-
} catch {
|
|
703
|
-
throw new CliError(`Invalid JSON in "${filePath}".`)
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* @param {string} dirPath
|
|
709
|
-
*/
|
|
710
|
-
async function listJsonFiles(dirPath) {
|
|
711
|
-
if (!(await pathExists(dirPath))) {
|
|
712
|
-
return []
|
|
713
|
-
}
|
|
714
|
-
const files = await walkFiles(dirPath)
|
|
715
|
-
return files.filter((filePath) => filePath.endsWith(".json")).sort()
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* @param {string} dirPath
|
|
720
|
-
*/
|
|
721
|
-
async function walkFiles(dirPath) {
|
|
722
|
-
/** @type {string[]} */
|
|
723
|
-
const files = []
|
|
724
|
-
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
725
|
-
for (const entry of entries) {
|
|
726
|
-
const entryPath = join(dirPath, entry.name)
|
|
727
|
-
if (entry.isDirectory()) {
|
|
728
|
-
files.push(...(await walkFiles(entryPath)))
|
|
729
|
-
} else if (entry.isFile()) {
|
|
730
|
-
files.push(entryPath)
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
return files
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* @param {unknown} value
|
|
738
|
-
* @param {string} filePath
|
|
739
|
-
*/
|
|
740
|
-
function readOptionalImageReference(value, filePath) {
|
|
741
|
-
if (value === undefined) {
|
|
742
|
-
return undefined
|
|
743
|
-
}
|
|
744
|
-
if (typeof value !== "string") {
|
|
745
|
-
throw new CliError(
|
|
746
|
-
`Invalid "image" in "${filePath}": expected a string when provided.`,
|
|
747
|
-
)
|
|
748
|
-
}
|
|
749
|
-
return value
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
/**
|
|
753
|
-
* @param {unknown} value
|
|
754
|
-
* @param {string} filePath
|
|
755
|
-
* @returns {Array<{type: "upload"|"link", url: string}>}
|
|
756
|
-
*/
|
|
757
|
-
function readContentArray(value, filePath) {
|
|
758
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
759
|
-
throw new CliError(
|
|
760
|
-
`Invalid "content" in "${filePath}": expected a non-empty array.`,
|
|
761
|
-
)
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
return value.map((item) => {
|
|
765
|
-
if (!isPlainObject(item)) {
|
|
766
|
-
throw new CliError(
|
|
767
|
-
`Invalid "content" in "${filePath}": expected array items to be objects.`,
|
|
768
|
-
)
|
|
769
|
-
}
|
|
770
|
-
if (item.type !== "upload" && item.type !== "link") {
|
|
771
|
-
throw new CliError(
|
|
772
|
-
`Invalid "content.type" in "${filePath}": expected "upload" or "link".`,
|
|
773
|
-
)
|
|
774
|
-
}
|
|
775
|
-
if (typeof item.url !== "string") {
|
|
776
|
-
throw new CliError(
|
|
777
|
-
`Invalid "content.url" in "${filePath}": expected a string.`,
|
|
778
|
-
)
|
|
779
|
-
}
|
|
780
|
-
return {
|
|
781
|
-
type: item.type,
|
|
782
|
-
url: item.url,
|
|
783
|
-
}
|
|
784
|
-
})
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
/**
|
|
788
|
-
* @param {unknown} value
|
|
789
|
-
*/
|
|
790
|
-
function isPlainObject(value) {
|
|
791
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/**
|
|
795
|
-
* @param {string} filePath
|
|
796
|
-
*/
|
|
797
|
-
async function pathExists(filePath) {
|
|
798
|
-
try {
|
|
799
|
-
await access(filePath)
|
|
800
|
-
return true
|
|
801
|
-
} catch {
|
|
802
|
-
return false
|
|
497
|
+
log.message(`• ${deletion.displayPath}`)
|
|
803
498
|
}
|
|
499
|
+
return promptConfirm("Delete these files? [y/N] ")
|
|
804
500
|
}
|
|
805
501
|
|
|
806
502
|
/**
|
|
@@ -812,22 +508,19 @@ function toPosixPath(filePath) {
|
|
|
812
508
|
|
|
813
509
|
/**
|
|
814
510
|
* @param {string} message
|
|
815
|
-
* @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
|
|
816
511
|
*/
|
|
817
|
-
async function defaultPromptText(message
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
return await rl.question(message)
|
|
821
|
-
} finally {
|
|
822
|
-
rl.close()
|
|
823
|
-
}
|
|
512
|
+
async function defaultPromptText(message) {
|
|
513
|
+
const answer = await text({ message })
|
|
514
|
+
return isCancel(answer) ? "" : String(answer)
|
|
824
515
|
}
|
|
825
516
|
|
|
826
517
|
/**
|
|
827
518
|
* @param {string} message
|
|
828
|
-
* @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
|
|
829
519
|
*/
|
|
830
|
-
async function defaultPromptConfirm(message
|
|
831
|
-
const answer =
|
|
832
|
-
|
|
520
|
+
async function defaultPromptConfirm(message) {
|
|
521
|
+
const answer = await confirm({
|
|
522
|
+
message,
|
|
523
|
+
initialValue: false,
|
|
524
|
+
})
|
|
525
|
+
return isCancel(answer) ? false : answer
|
|
833
526
|
}
|