@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
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { posix, resolve } from "node:path"
|
|
4
|
+
import { cwd as processCwd, stdin, stdout } from "node:process"
|
|
5
|
+
|
|
6
|
+
import { intro, isCancel, log, outro, text } from "@clack/prompts"
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
cliConfigFileName,
|
|
10
|
+
readCliConfig,
|
|
11
|
+
writeCliConfig,
|
|
12
|
+
} from "./support/cli-config.js"
|
|
13
|
+
import { CliError } from "./support/cli-error.js"
|
|
14
|
+
import { contentCollections } from "./support/content-collections.js"
|
|
15
|
+
import { pathExists } from "./support/filesystem.js"
|
|
16
|
+
|
|
17
|
+
const mediaDir = "src/content/media"
|
|
18
|
+
const defaultConcurrency = 24
|
|
19
|
+
const defaultTimeoutMs = 10_000
|
|
20
|
+
const headFallbackStatuses = new Set([405, 501])
|
|
21
|
+
const requestHeaders = {
|
|
22
|
+
accept: "*/*",
|
|
23
|
+
"user-agent": "@lightnet/cli link checker",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @typedef {import("./support/content-collections.js").MediaItem} MediaItem */
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {{
|
|
30
|
+
* timeout?: string
|
|
31
|
+
* }} CheckLinksOptions
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {{
|
|
36
|
+
* cwd?: string
|
|
37
|
+
* fetch?: typeof fetch
|
|
38
|
+
* isInteractive?: boolean
|
|
39
|
+
* promptText?: (message: string) => Promise<string>
|
|
40
|
+
* }} CheckLinksRuntime
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {{
|
|
45
|
+
* displayUrl: string
|
|
46
|
+
* resolvedUrl: string
|
|
47
|
+
* sources: Set<string>
|
|
48
|
+
* }} LinkReference
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {{
|
|
53
|
+
* ok: boolean
|
|
54
|
+
* reason?: string
|
|
55
|
+
* status?: number
|
|
56
|
+
* }} LinkCheckResult
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {CheckLinksOptions} options
|
|
61
|
+
* @param {CheckLinksRuntime} [runtime]
|
|
62
|
+
*/
|
|
63
|
+
export async function checkLinks(options, runtime = {}) {
|
|
64
|
+
const cwd = runtime.cwd ?? processCwd()
|
|
65
|
+
const interactive =
|
|
66
|
+
runtime.isInteractive ?? Boolean(stdin.isTTY && stdout.isTTY)
|
|
67
|
+
const promptText =
|
|
68
|
+
runtime.promptText ?? (async (message) => defaultPromptText(message))
|
|
69
|
+
const runFetch = runtime.fetch ?? fetch
|
|
70
|
+
const timeoutMs = parsePositiveInteger(
|
|
71
|
+
options.timeout,
|
|
72
|
+
"--timeout",
|
|
73
|
+
defaultTimeoutMs,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
intro("check-links")
|
|
77
|
+
|
|
78
|
+
await assertLightNetSiteRoot(cwd)
|
|
79
|
+
|
|
80
|
+
const collections = contentCollections(cwd)
|
|
81
|
+
const mediaItems = await collections.getMediaItems()
|
|
82
|
+
const siteUrl = await initSiteUrl({
|
|
83
|
+
cwd,
|
|
84
|
+
interactive,
|
|
85
|
+
mediaItems,
|
|
86
|
+
promptText,
|
|
87
|
+
})
|
|
88
|
+
const references = collectLinkReferences(mediaItems, siteUrl)
|
|
89
|
+
log.message(
|
|
90
|
+
`Checking ${references.length} unique links from ${mediaItems.length} media items.`,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if (references.length === 0) {
|
|
94
|
+
log.warn("No media content URLs found.")
|
|
95
|
+
outro("No issues found. 🎉")
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const results = await mapConcurrent(
|
|
100
|
+
references,
|
|
101
|
+
defaultConcurrency,
|
|
102
|
+
(reference) =>
|
|
103
|
+
checkLink(reference.resolvedUrl, { fetch: runFetch, timeoutMs }),
|
|
104
|
+
)
|
|
105
|
+
const checkedLinks = references
|
|
106
|
+
.map((reference, index) => ({ reference, result: results[index] }))
|
|
107
|
+
.sort((a, b) =>
|
|
108
|
+
a.reference.displayUrl.localeCompare(b.reference.displayUrl),
|
|
109
|
+
)
|
|
110
|
+
const protectedLinks = checkedLinks.filter((item) =>
|
|
111
|
+
isProtectedLink(item.result),
|
|
112
|
+
)
|
|
113
|
+
const unavailableLinks = checkedLinks.filter(
|
|
114
|
+
(item) => !item.result.ok && !isProtectedLink(item.result),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
printProtectedLinks(protectedLinks)
|
|
118
|
+
|
|
119
|
+
if (unavailableLinks.length === 0) {
|
|
120
|
+
if (protectedLinks.length > 0) {
|
|
121
|
+
outro("No unavailable links found. Some links could not be verified. ⚠️")
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
outro("No issues found. 🎉")
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
log.error(`Unavailable links (${unavailableLinks.length})`)
|
|
129
|
+
for (const { reference, result } of unavailableLinks) {
|
|
130
|
+
log.message(
|
|
131
|
+
`· ${reference.displayUrl} (${formatFailure(result)}, referenced by ${[
|
|
132
|
+
...reference.sources,
|
|
133
|
+
]
|
|
134
|
+
.toSorted()
|
|
135
|
+
.join(", ")})`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
outro("Issues found. 🚧")
|
|
140
|
+
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {{
|
|
146
|
+
* cwd: string
|
|
147
|
+
* interactive: boolean
|
|
148
|
+
* mediaItems: MediaItem[]
|
|
149
|
+
* promptText: (message: string) => Promise<string>
|
|
150
|
+
* }} args
|
|
151
|
+
*/
|
|
152
|
+
async function initSiteUrl({ cwd, interactive, mediaItems, promptText }) {
|
|
153
|
+
const needsSiteUrl = mediaItems.some((item) =>
|
|
154
|
+
item.content.some((contentItem) => isRootRelativeUrl(contentItem.url)),
|
|
155
|
+
)
|
|
156
|
+
if (!needsSiteUrl) {
|
|
157
|
+
return undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const config = await readCliConfig(cwd)
|
|
161
|
+
const configuredSiteUrl = parseSiteUrl(config)
|
|
162
|
+
if (configuredSiteUrl) {
|
|
163
|
+
return configuredSiteUrl
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!interactive) {
|
|
167
|
+
throw new CliError(
|
|
168
|
+
`Root-relative media content URLs require a site URL. Add "siteUrl" to "${cliConfigFileName}".`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
log.error(`Missing siteUrl in "${cliConfigFileName}".`)
|
|
173
|
+
while (true) {
|
|
174
|
+
const value = (await promptText("Site URL: ")).trim()
|
|
175
|
+
if (!value) {
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
const siteUrl = normalizeSiteUrl(value, "Site URL")
|
|
179
|
+
await writeCliConfig(cwd, {
|
|
180
|
+
...(isPlainObject(config) ? config : {}),
|
|
181
|
+
siteUrl,
|
|
182
|
+
})
|
|
183
|
+
return siteUrl
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {unknown} config
|
|
189
|
+
*/
|
|
190
|
+
function parseSiteUrl(config) {
|
|
191
|
+
if (!isPlainObject(config)) {
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
const siteUrl = /** @type {Record<string, unknown>} */ (config).siteUrl
|
|
195
|
+
if (typeof siteUrl !== "string" || !siteUrl.trim()) {
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
return normalizeSiteUrl(siteUrl, `"${cliConfigFileName}" siteUrl`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {MediaItem[]} mediaItems
|
|
203
|
+
* @param {string|undefined} siteUrl
|
|
204
|
+
*/
|
|
205
|
+
function collectLinkReferences(mediaItems, siteUrl) {
|
|
206
|
+
/** @type {Map<string, LinkReference>} */
|
|
207
|
+
const references = new Map()
|
|
208
|
+
for (const item of mediaItems) {
|
|
209
|
+
const sourceFileName = posix.basename(toPosixPath(item.path))
|
|
210
|
+
for (const contentItem of item.content) {
|
|
211
|
+
const resolvedUrl = resolveUrl(contentItem.url, siteUrl)
|
|
212
|
+
const current = references.get(resolvedUrl)
|
|
213
|
+
if (current) {
|
|
214
|
+
current.sources.add(sourceFileName)
|
|
215
|
+
if (contentItem.url.length < current.displayUrl.length) {
|
|
216
|
+
current.displayUrl = contentItem.url
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
references.set(resolvedUrl, {
|
|
220
|
+
displayUrl: contentItem.url,
|
|
221
|
+
resolvedUrl,
|
|
222
|
+
sources: new Set([sourceFileName]),
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return [...references.values()].sort((a, b) =>
|
|
228
|
+
a.displayUrl.localeCompare(b.displayUrl),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {string} url
|
|
234
|
+
* @param {string|undefined} siteUrl
|
|
235
|
+
*/
|
|
236
|
+
function resolveUrl(url, siteUrl) {
|
|
237
|
+
if (isRootRelativeUrl(url)) {
|
|
238
|
+
if (!siteUrl) {
|
|
239
|
+
throw new CliError(`Root-relative URL "${url}" requires a site URL.`)
|
|
240
|
+
}
|
|
241
|
+
const parsed = new URL(url, siteUrl)
|
|
242
|
+
parsed.hash = ""
|
|
243
|
+
return parsed.toString()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let parsed
|
|
247
|
+
try {
|
|
248
|
+
parsed = new URL(url)
|
|
249
|
+
} catch {
|
|
250
|
+
throw new CliError(`Invalid media content URL "${url}".`)
|
|
251
|
+
}
|
|
252
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
253
|
+
throw new CliError(
|
|
254
|
+
`Unsupported media content URL "${url}". Expected http or https.`,
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
parsed.hash = ""
|
|
258
|
+
return parsed.toString()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {string} url
|
|
263
|
+
* @param {{fetch: typeof fetch, timeoutMs: number}} options
|
|
264
|
+
* @returns {Promise<LinkCheckResult>}
|
|
265
|
+
*/
|
|
266
|
+
async function checkLink(url, options) {
|
|
267
|
+
const headResult = await fetchStatus(url, {
|
|
268
|
+
...options,
|
|
269
|
+
headers: requestHeaders,
|
|
270
|
+
method: "HEAD",
|
|
271
|
+
})
|
|
272
|
+
if (headResult.ok || !headFallbackStatuses.has(headResult.status ?? 0)) {
|
|
273
|
+
return headResult
|
|
274
|
+
}
|
|
275
|
+
return fetchStatus(url, {
|
|
276
|
+
...options,
|
|
277
|
+
headers: {
|
|
278
|
+
...requestHeaders,
|
|
279
|
+
"accept-encoding": "identity",
|
|
280
|
+
range: "bytes=0-0",
|
|
281
|
+
},
|
|
282
|
+
method: "GET",
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {{reference: LinkReference, result: LinkCheckResult}[]} links
|
|
288
|
+
*/
|
|
289
|
+
function printProtectedLinks(links) {
|
|
290
|
+
if (links.length === 0) {
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
log.warn(`Protected or forbidden links (${links.length})`)
|
|
295
|
+
for (const { reference, result } of links) {
|
|
296
|
+
log.message(
|
|
297
|
+
`· ${reference.displayUrl} (${formatFailure(result)}, referenced by ${[
|
|
298
|
+
...reference.sources,
|
|
299
|
+
]
|
|
300
|
+
.toSorted()
|
|
301
|
+
.join(", ")})`,
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* @param {string} url
|
|
308
|
+
* @param {{
|
|
309
|
+
* fetch: typeof fetch
|
|
310
|
+
* headers?: Record<string, string>
|
|
311
|
+
* method: "GET"|"HEAD"
|
|
312
|
+
* timeoutMs: number
|
|
313
|
+
* }} options
|
|
314
|
+
* @returns {Promise<LinkCheckResult>}
|
|
315
|
+
*/
|
|
316
|
+
async function fetchStatus(url, options) {
|
|
317
|
+
const controller = new AbortController()
|
|
318
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs)
|
|
319
|
+
try {
|
|
320
|
+
const response = await options.fetch(url, {
|
|
321
|
+
headers: options.headers,
|
|
322
|
+
method: options.method,
|
|
323
|
+
redirect: "follow",
|
|
324
|
+
signal: controller.signal,
|
|
325
|
+
})
|
|
326
|
+
await response.body?.cancel()
|
|
327
|
+
const ok = response.status >= 200 && response.status < 400
|
|
328
|
+
return { ok, status: response.status }
|
|
329
|
+
} catch (error) {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
reason: error instanceof Error ? error.message : "Request failed",
|
|
333
|
+
}
|
|
334
|
+
} finally {
|
|
335
|
+
clearTimeout(timeout)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @template T
|
|
341
|
+
* @template R
|
|
342
|
+
* @param {T[]} items
|
|
343
|
+
* @param {number} concurrency
|
|
344
|
+
* @param {(item: T) => Promise<R>} mapper
|
|
345
|
+
* @returns {Promise<R[]>}
|
|
346
|
+
*/
|
|
347
|
+
async function mapConcurrent(items, concurrency, mapper) {
|
|
348
|
+
/** @type {R[]} */
|
|
349
|
+
const results = []
|
|
350
|
+
let nextIndex = 0
|
|
351
|
+
const workerCount = Math.min(concurrency, items.length)
|
|
352
|
+
await Promise.all(
|
|
353
|
+
Array.from({ length: workerCount }, async () => {
|
|
354
|
+
while (true) {
|
|
355
|
+
const index = nextIndex
|
|
356
|
+
nextIndex += 1
|
|
357
|
+
const item = items[index]
|
|
358
|
+
if (item === undefined) {
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
results[index] = await mapper(item)
|
|
362
|
+
}
|
|
363
|
+
}),
|
|
364
|
+
)
|
|
365
|
+
return results
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* @param {string|undefined} rawValue
|
|
370
|
+
* @param {string} optionName
|
|
371
|
+
* @param {number} defaultValue
|
|
372
|
+
*/
|
|
373
|
+
function parsePositiveInteger(rawValue, optionName, defaultValue) {
|
|
374
|
+
if (rawValue === undefined) {
|
|
375
|
+
return defaultValue
|
|
376
|
+
}
|
|
377
|
+
const value = Number(rawValue)
|
|
378
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
379
|
+
throw new CliError(`Expected "${optionName}" to be a positive integer.`)
|
|
380
|
+
}
|
|
381
|
+
return value
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* @param {string} value
|
|
386
|
+
* @param {string} label
|
|
387
|
+
*/
|
|
388
|
+
function normalizeSiteUrl(value, label) {
|
|
389
|
+
let parsed
|
|
390
|
+
try {
|
|
391
|
+
parsed = new URL(value)
|
|
392
|
+
} catch {
|
|
393
|
+
throw new CliError(`Invalid ${label}: expected an absolute http(s) URL.`)
|
|
394
|
+
}
|
|
395
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
396
|
+
throw new CliError(`Invalid ${label}: expected an absolute http(s) URL.`)
|
|
397
|
+
}
|
|
398
|
+
return parsed.toString().replace(/\/+$/, "")
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @param {string} cwd
|
|
403
|
+
*/
|
|
404
|
+
async function assertLightNetSiteRoot(cwd) {
|
|
405
|
+
if (!(await pathExists(resolve(cwd, mediaDir)))) {
|
|
406
|
+
throw new CliError(
|
|
407
|
+
'Expected a LightNet site root with "src/content/media". Run this command inside a LightNet site project.',
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* @param {string} url
|
|
414
|
+
*/
|
|
415
|
+
function isRootRelativeUrl(url) {
|
|
416
|
+
return url.startsWith("/") && !url.startsWith("//")
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param {LinkCheckResult} result
|
|
421
|
+
*/
|
|
422
|
+
function formatFailure(result) {
|
|
423
|
+
if (result.status !== undefined) {
|
|
424
|
+
return `status ${result.status}`
|
|
425
|
+
}
|
|
426
|
+
return result.reason ?? "request failed"
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {LinkCheckResult} result
|
|
431
|
+
*/
|
|
432
|
+
function isProtectedLink(result) {
|
|
433
|
+
return !result.ok && result.status === 403
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @param {string} filePath
|
|
438
|
+
*/
|
|
439
|
+
function toPosixPath(filePath) {
|
|
440
|
+
return filePath.split("\\").join("/")
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* @param {string} message
|
|
445
|
+
*/
|
|
446
|
+
async function defaultPromptText(message) {
|
|
447
|
+
const answer = await text({ message })
|
|
448
|
+
return isCancel(answer) ? "" : String(answer)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {unknown} value
|
|
453
|
+
*/
|
|
454
|
+
function isPlainObject(value) {
|
|
455
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
456
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import { spawn } from "node:child_process"
|
|
3
4
|
import { readFile } from "node:fs/promises"
|
|
4
5
|
import { resolve } from "node:path"
|
|
5
6
|
import { cwd } from "node:process"
|
|
6
7
|
|
|
8
|
+
import { confirm, intro, log, outro, taskLog } from "@clack/prompts"
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
11
|
* @typedef {{
|
|
9
12
|
* type: "lightnet" | "user" | "map"
|
|
@@ -19,29 +22,46 @@ import { cwd } from "node:process"
|
|
|
19
22
|
* }} Languages
|
|
20
23
|
*/
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{
|
|
27
|
+
* build?: boolean
|
|
28
|
+
* }} CheckTranslationsOptions
|
|
29
|
+
*/
|
|
30
|
+
|
|
22
31
|
const lightnetCachePath = resolve(cwd(), "node_modules", ".cache", "lightnet")
|
|
23
32
|
|
|
24
33
|
/** @type {{type:Translation["type"], title:string, action:string}[]} */
|
|
25
34
|
const translationSources = [
|
|
26
35
|
{
|
|
27
36
|
type: "lightnet",
|
|
28
|
-
title: "LightNet built-in translations",
|
|
37
|
+
title: "Missing LightNet built-in translations",
|
|
29
38
|
action: "Add the missing entries in your /src/translations/*.yaml files.",
|
|
30
39
|
},
|
|
31
40
|
{
|
|
32
41
|
type: "user",
|
|
33
|
-
title: "
|
|
42
|
+
title: "Incomplete user translations",
|
|
34
43
|
action: "Add the missing entries in your /src/translations/*.yaml files.",
|
|
35
44
|
},
|
|
36
45
|
{
|
|
37
46
|
type: "map",
|
|
38
|
-
title: "
|
|
47
|
+
title: "Incomplete inline translation maps",
|
|
39
48
|
action:
|
|
40
49
|
"Update the inline translation map to include values for every configured site language.",
|
|
41
50
|
},
|
|
42
51
|
]
|
|
43
52
|
|
|
44
|
-
|
|
53
|
+
/**
|
|
54
|
+
* @param {CheckTranslationsOptions} [options]
|
|
55
|
+
*/
|
|
56
|
+
export async function checkTranslations(options = {}) {
|
|
57
|
+
intro("check-translations")
|
|
58
|
+
|
|
59
|
+
const buildAvailable = await runBuild(options.build)
|
|
60
|
+
if (!buildAvailable) {
|
|
61
|
+
outro("Build failed. 🚧")
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
const translations = await readTranslations()
|
|
46
66
|
const languages = await readLanguages()
|
|
47
67
|
if (!translations || !languages || translations.length === 0) {
|
|
@@ -55,6 +75,7 @@ export async function checkTranslations() {
|
|
|
55
75
|
.filter((translation) => translation.missingLocales.length > 0)
|
|
56
76
|
|
|
57
77
|
if (incompleteTranslations.length === 0) {
|
|
78
|
+
outro("No issues found. 🎉")
|
|
58
79
|
return true
|
|
59
80
|
}
|
|
60
81
|
|
|
@@ -63,15 +84,80 @@ export async function checkTranslations() {
|
|
|
63
84
|
(translation) => translation.type,
|
|
64
85
|
)
|
|
65
86
|
|
|
66
|
-
|
|
87
|
+
log.error("Translation check failed")
|
|
67
88
|
for (const source of translationSources) {
|
|
68
89
|
printMissingTranslations(source, grouped[source.type])
|
|
69
90
|
}
|
|
70
91
|
|
|
71
|
-
|
|
92
|
+
outro("Issues found. 🚧")
|
|
93
|
+
|
|
72
94
|
return false
|
|
73
95
|
}
|
|
74
96
|
|
|
97
|
+
/**
|
|
98
|
+
*
|
|
99
|
+
* @param {boolean|undefined} build
|
|
100
|
+
* @returns
|
|
101
|
+
*/
|
|
102
|
+
async function runBuild(build) {
|
|
103
|
+
const shouldRunBuild =
|
|
104
|
+
build ??
|
|
105
|
+
(await confirm({
|
|
106
|
+
message:
|
|
107
|
+
"Run pnpm build now? Command requires an up-to-date dist/ directory.",
|
|
108
|
+
initialValue: false,
|
|
109
|
+
}))
|
|
110
|
+
if (!shouldRunBuild) {
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
const buildLog = taskLog({
|
|
114
|
+
title: "Running pnpm build",
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const child = spawn("pnpm", ["build"], {
|
|
118
|
+
shell: process.platform === "win32",
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
child.stdout?.setEncoding("utf8")
|
|
122
|
+
child.stderr?.setEncoding("utf8")
|
|
123
|
+
|
|
124
|
+
child.stdout?.on("data", (chunk) => {
|
|
125
|
+
for (const line of chunk.trimEnd().split("\n")) {
|
|
126
|
+
if (line) {
|
|
127
|
+
buildLog.message(line)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
child.stderr?.on("data", (chunk) => {
|
|
133
|
+
for (const line of chunk.trimEnd().split("\n")) {
|
|
134
|
+
if (line) {
|
|
135
|
+
buildLog.message(line)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await new Promise((resolve, reject) => {
|
|
142
|
+
child.on("error", reject)
|
|
143
|
+
|
|
144
|
+
child.on("close", (code) => {
|
|
145
|
+
if (code === 0) {
|
|
146
|
+
resolve(0)
|
|
147
|
+
} else {
|
|
148
|
+
reject(new Error(`pnpm build failed with exit code ${code}`))
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
buildLog.success("Build completed")
|
|
153
|
+
return true
|
|
154
|
+
} catch (e) {
|
|
155
|
+
buildLog.message(`${e}`)
|
|
156
|
+
buildLog.error("pnpm build failed")
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
75
161
|
/**
|
|
76
162
|
*
|
|
77
163
|
* @param {{title:string, action:string}} source
|
|
@@ -82,8 +168,7 @@ function printMissingTranslations(source, translations) {
|
|
|
82
168
|
return
|
|
83
169
|
}
|
|
84
170
|
|
|
85
|
-
|
|
86
|
-
console.log(source.title)
|
|
171
|
+
log.warn(source.title)
|
|
87
172
|
translations
|
|
88
173
|
.toSorted(
|
|
89
174
|
(t1, t2) =>
|
|
@@ -91,11 +176,10 @@ function printMissingTranslations(source, translations) {
|
|
|
91
176
|
t1.key.localeCompare(t2.key),
|
|
92
177
|
)
|
|
93
178
|
.forEach(({ key, missingLocales }) => {
|
|
94
|
-
|
|
95
|
-
console.log(` Missing locales: ${missingLocales.join(", ")}`)
|
|
179
|
+
log.message(`• ${key} > Missing: ${missingLocales.join(", ")}`)
|
|
96
180
|
})
|
|
97
181
|
|
|
98
|
-
|
|
182
|
+
log.message(`Action: ${source.action}`)
|
|
99
183
|
}
|
|
100
184
|
|
|
101
185
|
/**
|
|
@@ -122,10 +206,8 @@ async function readTranslations() {
|
|
|
122
206
|
.filter((line) => line.trim())
|
|
123
207
|
.map((line) => JSON.parse(line))
|
|
124
208
|
} catch {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"Action: Run build and try lightnet check-translations again.",
|
|
128
|
-
)
|
|
209
|
+
log.error("No translation build cache found.")
|
|
210
|
+
log.error("Action: Run build and try lightnet check-translations again.")
|
|
129
211
|
return undefined
|
|
130
212
|
}
|
|
131
213
|
}
|
|
@@ -141,10 +223,8 @@ async function readLanguages() {
|
|
|
141
223
|
)
|
|
142
224
|
return JSON.parse(languagesText)
|
|
143
225
|
} catch {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
"Action: Run build and try lightnet check-translations again.",
|
|
147
|
-
)
|
|
226
|
+
log.error("No language manifest found from the last build.")
|
|
227
|
+
log.error("Action: Run build and try lightnet check-translations again.")
|
|
148
228
|
return undefined
|
|
149
229
|
}
|
|
150
230
|
}
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Command } from "commander"
|
|
|
5
5
|
|
|
6
6
|
import pkg from "../package.json" with { type: "json" }
|
|
7
7
|
import { checkFiles } from "./check-files.js"
|
|
8
|
+
import { checkLinks } from "./check-links.js"
|
|
8
9
|
import { checkTranslations } from "./check-translations.js"
|
|
9
10
|
import { CliError } from "./support/cli-error.js"
|
|
10
11
|
const { version } = pkg
|
|
@@ -20,8 +21,10 @@ program
|
|
|
20
21
|
program
|
|
21
22
|
.command("check-translations")
|
|
22
23
|
.description("check if last build has been missing any translations")
|
|
23
|
-
.
|
|
24
|
-
|
|
24
|
+
.option("--build", "run pnpm build before checking translations")
|
|
25
|
+
.option("--no-build", "skip the build prompt and use the latest dist/ output")
|
|
26
|
+
.action(async (options) => {
|
|
27
|
+
const checkSuccessful = await checkTranslations(options)
|
|
25
28
|
commandExitCode = checkSuccessful ? 0 : 1
|
|
26
29
|
})
|
|
27
30
|
|
|
@@ -31,7 +34,7 @@ program
|
|
|
31
34
|
"check for missing and orphaned content files and thumbnails in a LightNet site",
|
|
32
35
|
)
|
|
33
36
|
.option("--fix", "remove orphaned files")
|
|
34
|
-
.option("--
|
|
37
|
+
.option("--no-confirm", "skip deletion confirmation when used with --fix")
|
|
35
38
|
.option(
|
|
36
39
|
"--r2",
|
|
37
40
|
"validate remote content files in Cloudflare R2 instead of public/files",
|
|
@@ -54,5 +57,23 @@ program
|
|
|
54
57
|
}
|
|
55
58
|
})
|
|
56
59
|
|
|
60
|
+
program
|
|
61
|
+
.command("check-links")
|
|
62
|
+
.description("check media content links in a LightNet site")
|
|
63
|
+
.option("--timeout <ms>", "request timeout per link in milliseconds")
|
|
64
|
+
.action(async (options) => {
|
|
65
|
+
try {
|
|
66
|
+
const checkSuccessful = await checkLinks(options)
|
|
67
|
+
commandExitCode = checkSuccessful ? 0 : 1
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof CliError) {
|
|
70
|
+
console.error(error.message)
|
|
71
|
+
commandExitCode = 1
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
57
78
|
await program.parseAsync()
|
|
58
79
|
process.exitCode = commandExitCode
|