@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.
@@ -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: "User translation files",
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: "Inline translation maps",
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
- export async function checkTranslations() {
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
- console.log("Translation check failed")
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
- console.log("\n\n")
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
- console.log()
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
- console.log(`- ${key}`)
95
- console.log(` Missing locales: ${missingLocales.join(", ")}`)
179
+ log.message(`• ${key} > Missing: ${missingLocales.join(", ")}`)
96
180
  })
97
181
 
98
- console.log(`Action: ${source.action}`)
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
- console.error("No translation build cache found.")
126
- console.error(
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
- console.error("No language manifest found from the last build.")
145
- console.error(
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
- .action(async () => {
24
- const checkSuccessful = await checkTranslations()
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("--yes", "skip deletion confirmation when used with --fix")
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