@lightnet/cli 4.2.0 → 4.4.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 +24 -0
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/check-files.js +206 -512
- package/src/check-links.js +456 -0
- package/src/check-translations.js +99 -19
- package/src/index.js +24 -3
- package/src/support/cli-config.js +37 -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,81 +1,59 @@
|
|
|
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 {{
|
|
34
26
|
* scope?: string
|
|
35
27
|
* fix?: boolean
|
|
36
|
-
*
|
|
28
|
+
* confirm?: boolean
|
|
37
29
|
* r2?: boolean
|
|
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,79 +140,103 @@ 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) {
|
|
218
|
-
|
|
188
|
+
const shouldSkipConfirmation = options.confirm === false
|
|
189
|
+
if (!shouldSkipConfirmation && !interactive) {
|
|
219
190
|
throw new CliError(
|
|
220
|
-
'Deletion requires confirmation. Re-run with "--fix --
|
|
191
|
+
'Deletion requires confirmation. Re-run with "--fix --no-confirm" in non-interactive environments.',
|
|
221
192
|
)
|
|
222
193
|
}
|
|
223
194
|
const shouldDelete =
|
|
224
|
-
|
|
195
|
+
shouldSkipConfirmation ||
|
|
225
196
|
(await confirmDeletion({
|
|
226
197
|
deletions,
|
|
227
|
-
logger,
|
|
228
198
|
promptConfirm,
|
|
229
199
|
}))
|
|
230
200
|
|
|
231
201
|
if (shouldDelete) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
202
|
+
const deletedContentTargets = contentFileStorage
|
|
203
|
+
? await contentFileStorage.delete(
|
|
204
|
+
contentDeletions.map((deletion) => deletion.target),
|
|
205
|
+
)
|
|
206
|
+
: []
|
|
207
|
+
const deletedLocalTargets = [
|
|
208
|
+
...(await mediaThumbnailStorage.delete(
|
|
209
|
+
localDeletions
|
|
210
|
+
.filter((deletion) => deletion.target.startsWith(mediaImagesDir))
|
|
211
|
+
.map((deletion) => deletion.target),
|
|
212
|
+
)),
|
|
213
|
+
...(await categoryThumbnailStorage.delete(
|
|
214
|
+
localDeletions
|
|
215
|
+
.filter((deletion) =>
|
|
216
|
+
deletion.target.startsWith(categoryImagesDir),
|
|
217
|
+
)
|
|
218
|
+
.map((deletion) => deletion.target),
|
|
219
|
+
)),
|
|
220
|
+
]
|
|
221
|
+
const deletedTargets = new Set([
|
|
222
|
+
...deletedContentTargets,
|
|
223
|
+
...deletedLocalTargets,
|
|
224
|
+
])
|
|
225
|
+
for (const deletion of [...contentDeletions, ...localDeletions]) {
|
|
226
|
+
if (deletedTargets.has(deletion.target)) {
|
|
227
|
+
removedItems.push(deletion.displayPath)
|
|
228
|
+
} else {
|
|
229
|
+
log.error(`Failed to delete "${deletion.displayPath}".`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
241
232
|
orphanedContentFiles = orphanedContentFiles.filter(
|
|
242
|
-
(item) => !
|
|
233
|
+
(item) => !deletedTargets.has(item),
|
|
243
234
|
)
|
|
244
235
|
orphanedMediaThumbnails = orphanedMediaThumbnails.filter(
|
|
245
|
-
(item) => !
|
|
236
|
+
(item) => !deletedTargets.has(item),
|
|
246
237
|
)
|
|
247
238
|
orphanedCategoryThumbnails = orphanedCategoryThumbnails.filter(
|
|
248
|
-
(item) => !
|
|
239
|
+
(item) => !deletedTargets.has(item),
|
|
249
240
|
)
|
|
250
241
|
}
|
|
251
242
|
}
|
|
@@ -259,64 +250,59 @@ export async function checkFiles(options, runtime = {}) {
|
|
|
259
250
|
orphanedCategoryThumbnails.length > 0
|
|
260
251
|
|
|
261
252
|
if (!hasIssues && removedItems.length === 0) {
|
|
253
|
+
outro("No issues found. 🎉")
|
|
262
254
|
return true
|
|
263
255
|
}
|
|
264
256
|
|
|
265
257
|
for (const warning of warnings) {
|
|
266
|
-
|
|
258
|
+
log.warn(warning)
|
|
267
259
|
}
|
|
268
260
|
|
|
269
261
|
printMissingReferenceSection(
|
|
270
|
-
logger,
|
|
271
262
|
"Missing referenced content files",
|
|
272
263
|
missingContentFiles,
|
|
273
264
|
)
|
|
274
265
|
printSection(
|
|
275
|
-
logger,
|
|
276
266
|
options.r2 ? "Orphaned R2 objects" : "Orphaned local content files",
|
|
277
|
-
orphanedContentFiles
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
printSection(
|
|
281
|
-
logger,
|
|
282
|
-
"Orphaned category thumbnails",
|
|
283
|
-
orphanedCategoryThumbnails,
|
|
267
|
+
orphanedContentFiles.map((filePath) =>
|
|
268
|
+
formatContentFilePath(filePath, options),
|
|
269
|
+
),
|
|
284
270
|
)
|
|
285
|
-
printSection(
|
|
271
|
+
printSection("Orphaned media thumbnails", orphanedMediaThumbnails)
|
|
272
|
+
printSection("Orphaned category thumbnails", orphanedCategoryThumbnails)
|
|
273
|
+
printSection("Removed items", removedItems)
|
|
274
|
+
|
|
275
|
+
outro(hasIssues ? "Issues found. 🚧" : "Cleanup complete. 🧹")
|
|
286
276
|
|
|
287
277
|
return !hasIssues
|
|
288
278
|
}
|
|
289
279
|
|
|
290
280
|
/**
|
|
291
|
-
* @param {Logger} logger
|
|
292
281
|
* @param {string} title
|
|
293
282
|
* @param {string[]} items
|
|
294
283
|
*/
|
|
295
|
-
function printSection(
|
|
284
|
+
function printSection(title, items) {
|
|
296
285
|
if (items.length === 0) {
|
|
297
286
|
return
|
|
298
287
|
}
|
|
299
|
-
|
|
300
|
-
logger.error(`${title} (${items.length})`)
|
|
288
|
+
log.warn(`${title} (${items.length})`)
|
|
301
289
|
for (const item of items) {
|
|
302
|
-
|
|
290
|
+
log.message(`• ${item}`)
|
|
303
291
|
}
|
|
304
292
|
}
|
|
305
293
|
|
|
306
294
|
/**
|
|
307
|
-
* @param {Logger} logger
|
|
308
295
|
* @param {string} title
|
|
309
296
|
* @param {MissingReference[]} items
|
|
310
297
|
*/
|
|
311
|
-
function printMissingReferenceSection(
|
|
298
|
+
function printMissingReferenceSection(title, items) {
|
|
312
299
|
if (items.length === 0) {
|
|
313
300
|
return
|
|
314
301
|
}
|
|
315
|
-
|
|
316
|
-
logger.error(`${title} (${items.length})`)
|
|
302
|
+
log.error(`${title} (${items.length})`)
|
|
317
303
|
for (const item of items) {
|
|
318
|
-
|
|
319
|
-
|
|
304
|
+
log.message(
|
|
305
|
+
`• ${item.path} (referenced by ${item.sources.toSorted().join(", ")})`,
|
|
320
306
|
)
|
|
321
307
|
}
|
|
322
308
|
}
|
|
@@ -361,106 +347,17 @@ async function assertLightNetSiteRoot(cwd) {
|
|
|
361
347
|
}
|
|
362
348
|
|
|
363
349
|
/**
|
|
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
|
|
350
|
+
* @param {MediaItem[]} mediaItems
|
|
351
|
+
* @param {Category[]} categories
|
|
352
|
+
* @param {FileStorage} mediaThumbnailStorage
|
|
353
|
+
* @param {FileStorage} categoryThumbnailStorage
|
|
462
354
|
*/
|
|
463
|
-
async function validateThumbnails(
|
|
355
|
+
async function validateThumbnails(
|
|
356
|
+
mediaItems,
|
|
357
|
+
categories,
|
|
358
|
+
mediaThumbnailStorage,
|
|
359
|
+
categoryThumbnailStorage,
|
|
360
|
+
) {
|
|
464
361
|
const mediaReferences = new Set(
|
|
465
362
|
mediaItems
|
|
466
363
|
.map((item) => toThumbnailPath("media", item.image))
|
|
@@ -472,17 +369,10 @@ async function validateThumbnails(cwd, mediaItems, categories) {
|
|
|
472
369
|
.filter((value) => value !== undefined),
|
|
473
370
|
)
|
|
474
371
|
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const categoryFiles = await collectLocalFiles(
|
|
480
|
-
resolve(cwd, categoryImagesDir),
|
|
481
|
-
{
|
|
482
|
-
includeRootPrefix: categoryImagesDir,
|
|
483
|
-
ignoreJunk: true,
|
|
484
|
-
},
|
|
485
|
-
)
|
|
372
|
+
const initializedMediaStorage = await mediaThumbnailStorage.init()
|
|
373
|
+
const initializedCategoryStorage = await categoryThumbnailStorage.init()
|
|
374
|
+
const mediaFiles = await initializedMediaStorage.list()
|
|
375
|
+
const categoryFiles = await initializedCategoryStorage.list()
|
|
486
376
|
|
|
487
377
|
return {
|
|
488
378
|
orphanedMediaThumbnails: mediaFiles.filter(
|
|
@@ -495,51 +385,66 @@ async function validateThumbnails(cwd, mediaItems, categories) {
|
|
|
495
385
|
}
|
|
496
386
|
|
|
497
387
|
/**
|
|
498
|
-
* @param {
|
|
388
|
+
* @param {{
|
|
389
|
+
* mediaItems: MediaItem[]
|
|
390
|
+
* storage: FileStorage
|
|
391
|
+
* }} args
|
|
499
392
|
*/
|
|
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
|
-
}
|
|
393
|
+
async function validateContentFiles({ mediaItems, storage }) {
|
|
394
|
+
const initializedStorage = await storage.init()
|
|
395
|
+
const references = collectContentReferences(mediaItems, initializedStorage)
|
|
396
|
+
if (references.size === 0) {
|
|
397
|
+
return {
|
|
398
|
+
missingReferences: /** @type {MissingReference[]} */ ([]),
|
|
399
|
+
orphanedFiles: /** @type {string[]} */ ([]),
|
|
400
|
+
referenceCount: 0,
|
|
513
401
|
}
|
|
514
402
|
}
|
|
515
|
-
|
|
403
|
+
|
|
404
|
+
const files = await initializedStorage.list()
|
|
405
|
+
const fileSet = new Set(files)
|
|
406
|
+
|
|
407
|
+
const missingReferences = [...references.entries()]
|
|
408
|
+
.filter(([path]) => !fileSet.has(path))
|
|
409
|
+
.map(([, reference]) => ({
|
|
410
|
+
path: reference.displayPath,
|
|
411
|
+
sources: [...reference.sources].toSorted(),
|
|
412
|
+
}))
|
|
413
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
414
|
+
|
|
415
|
+
const orphanedFiles = files
|
|
416
|
+
.filter((filePath) => !references.has(filePath))
|
|
417
|
+
.sort()
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
missingReferences,
|
|
421
|
+
orphanedFiles,
|
|
422
|
+
referenceCount: references.size,
|
|
423
|
+
}
|
|
516
424
|
}
|
|
517
425
|
|
|
518
426
|
/**
|
|
519
|
-
* @param {
|
|
520
|
-
* @param {
|
|
427
|
+
* @param {MediaItem[]} mediaItems
|
|
428
|
+
* @param {FileStorage} storage
|
|
521
429
|
*/
|
|
522
|
-
function
|
|
430
|
+
function collectContentReferences(mediaItems, storage) {
|
|
523
431
|
const references = new Map()
|
|
524
|
-
const normalizedBase = publicUrl.replace(/\/+$/, "")
|
|
525
432
|
for (const item of mediaItems) {
|
|
526
433
|
const sourceFileName = posix.basename(toPosixPath(item.path))
|
|
527
434
|
for (const contentItem of item.content) {
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
)
|
|
435
|
+
if (contentItem.type !== "upload") {
|
|
436
|
+
continue
|
|
437
|
+
}
|
|
438
|
+
const filePath = storage.toPath(contentItem.url)
|
|
439
|
+
if (!filePath) {
|
|
532
440
|
continue
|
|
533
441
|
}
|
|
534
|
-
const
|
|
535
|
-
.slice(normalizedBase.length)
|
|
536
|
-
.replace(/^\/+/, "")
|
|
537
|
-
const current = references.get(contentItem.url)
|
|
442
|
+
const current = references.get(filePath)
|
|
538
443
|
if (current) {
|
|
539
444
|
current.sources.add(sourceFileName)
|
|
540
445
|
} else {
|
|
541
|
-
references.set(
|
|
542
|
-
|
|
446
|
+
references.set(filePath, {
|
|
447
|
+
displayPath: contentItem.url,
|
|
543
448
|
sources: new Set([sourceFileName]),
|
|
544
449
|
})
|
|
545
450
|
}
|
|
@@ -548,58 +453,19 @@ function collectR2ContentReferences(mediaItems, publicUrl) {
|
|
|
548
453
|
return references
|
|
549
454
|
}
|
|
550
455
|
|
|
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
456
|
/**
|
|
595
457
|
* @param {"media"|"categories"} kind
|
|
596
458
|
* @param {string|undefined} image
|
|
597
459
|
*/
|
|
598
460
|
function toThumbnailPath(kind, image) {
|
|
599
|
-
if (typeof image !== "string"
|
|
461
|
+
if (typeof image !== "string") {
|
|
600
462
|
return undefined
|
|
601
463
|
}
|
|
602
|
-
const relativePath = image.
|
|
464
|
+
const relativePath = image.startsWith("./images/")
|
|
465
|
+
? image.slice("./images/".length)
|
|
466
|
+
: image.startsWith("images/")
|
|
467
|
+
? image.slice("images/".length)
|
|
468
|
+
: undefined
|
|
603
469
|
if (
|
|
604
470
|
!relativePath ||
|
|
605
471
|
relativePath.startsWith("/") ||
|
|
@@ -613,194 +479,25 @@ function toThumbnailPath(kind, image) {
|
|
|
613
479
|
}
|
|
614
480
|
|
|
615
481
|
/**
|
|
616
|
-
* @param {
|
|
617
|
-
*
|
|
618
|
-
* logger: Logger
|
|
619
|
-
* promptConfirm: (message:string)=>Promise<boolean>
|
|
620
|
-
* }} args
|
|
482
|
+
* @param {string} path
|
|
483
|
+
* @param {CheckFilesOptions} options
|
|
621
484
|
*/
|
|
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] ")
|
|
485
|
+
function formatContentFilePath(path, options) {
|
|
486
|
+
return options.r2 ? path : toLocalContentDisplayPath(path)
|
|
629
487
|
}
|
|
630
488
|
|
|
631
489
|
/**
|
|
632
490
|
* @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}>
|
|
491
|
+
* deletions: {displayPath:string, target:string}[]
|
|
492
|
+
* promptConfirm: (message:string)=>Promise<boolean>
|
|
640
493
|
* }} args
|
|
641
494
|
*/
|
|
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 = []
|
|
495
|
+
async function confirmDeletion({ deletions, promptConfirm }) {
|
|
496
|
+
log.warn(`Files to remove (${deletions.length})`)
|
|
654
497
|
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
|
|
498
|
+
log.message(`• ${deletion.displayPath}`)
|
|
803
499
|
}
|
|
500
|
+
return promptConfirm("Delete these files? [y/N] ")
|
|
804
501
|
}
|
|
805
502
|
|
|
806
503
|
/**
|
|
@@ -812,22 +509,19 @@ function toPosixPath(filePath) {
|
|
|
812
509
|
|
|
813
510
|
/**
|
|
814
511
|
* @param {string} message
|
|
815
|
-
* @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
|
|
816
512
|
*/
|
|
817
|
-
async function defaultPromptText(message
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
return await rl.question(message)
|
|
821
|
-
} finally {
|
|
822
|
-
rl.close()
|
|
823
|
-
}
|
|
513
|
+
async function defaultPromptText(message) {
|
|
514
|
+
const answer = await text({ message })
|
|
515
|
+
return isCancel(answer) ? "" : String(answer)
|
|
824
516
|
}
|
|
825
517
|
|
|
826
518
|
/**
|
|
827
519
|
* @param {string} message
|
|
828
|
-
* @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream}} io
|
|
829
520
|
*/
|
|
830
|
-
async function defaultPromptConfirm(message
|
|
831
|
-
const answer =
|
|
832
|
-
|
|
521
|
+
async function defaultPromptConfirm(message) {
|
|
522
|
+
const answer = await confirm({
|
|
523
|
+
message,
|
|
524
|
+
initialValue: false,
|
|
525
|
+
})
|
|
526
|
+
return isCancel(answer) ? false : answer
|
|
833
527
|
}
|