@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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { posix, relative, resolve } from "node:path"
|
|
4
|
+
|
|
5
|
+
import { CliError } from "./cli-error.js"
|
|
6
|
+
import { jsonFiles, pathExists, readJsonFile } from "./filesystem.js"
|
|
7
|
+
|
|
8
|
+
const mediaDir = "src/content/media"
|
|
9
|
+
const categoriesDir = "src/content/categories"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{
|
|
13
|
+
* type: "upload"|"link"
|
|
14
|
+
* url: string
|
|
15
|
+
* }} MediaContent
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{
|
|
20
|
+
* path: string
|
|
21
|
+
* id: string
|
|
22
|
+
* image: string
|
|
23
|
+
* content: MediaContent[]
|
|
24
|
+
* }} MediaItem
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* path: string
|
|
30
|
+
* id: string
|
|
31
|
+
* image?: string
|
|
32
|
+
* }} Category
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {{
|
|
37
|
+
* getMediaItems: () => Promise<MediaItem[]>
|
|
38
|
+
* getCategories: () => Promise<Category[]>
|
|
39
|
+
* }} ContentCollections
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} cwd
|
|
44
|
+
* @returns {ContentCollections}
|
|
45
|
+
*/
|
|
46
|
+
export function contentCollections(cwd) {
|
|
47
|
+
return {
|
|
48
|
+
async getMediaItems() {
|
|
49
|
+
const files = await jsonFiles(resolve(cwd, mediaDir))
|
|
50
|
+
return Promise.all(files.map((filePath) => readMediaItem(cwd, filePath)))
|
|
51
|
+
},
|
|
52
|
+
async getCategories() {
|
|
53
|
+
const absoluteDir = resolve(cwd, categoriesDir)
|
|
54
|
+
if (!(await pathExists(absoluteDir))) {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
const files = await jsonFiles(absoluteDir)
|
|
58
|
+
return Promise.all(files.map((filePath) => readCategory(cwd, filePath)))
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} cwd
|
|
65
|
+
* @param {string} filePath
|
|
66
|
+
* @returns {Promise<MediaItem>}
|
|
67
|
+
*/
|
|
68
|
+
async function readMediaItem(cwd, filePath) {
|
|
69
|
+
const value = await readJsonFile(filePath)
|
|
70
|
+
if (!isPlainObject(value)) {
|
|
71
|
+
throw new CliError(
|
|
72
|
+
`Invalid media item in "${filePath}": expected an object.`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const image = readRequiredImageReference(value.image, filePath)
|
|
77
|
+
const content = readContentArray(value.content, filePath)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
path: filePath,
|
|
81
|
+
id: toCollectionId(cwd, mediaDir, filePath),
|
|
82
|
+
content,
|
|
83
|
+
image,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} cwd
|
|
89
|
+
* @param {string} filePath
|
|
90
|
+
* @returns {Promise<Category>}
|
|
91
|
+
*/
|
|
92
|
+
async function readCategory(cwd, filePath) {
|
|
93
|
+
const value = await readJsonFile(filePath)
|
|
94
|
+
if (!isPlainObject(value)) {
|
|
95
|
+
throw new CliError(`Invalid category in "${filePath}": expected an object.`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
path: filePath,
|
|
100
|
+
id: toCollectionId(cwd, categoriesDir, filePath),
|
|
101
|
+
image: readOptionalImageReference(value.image, filePath),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string} cwd
|
|
107
|
+
* @param {string} collectionDir
|
|
108
|
+
* @param {string} filePath
|
|
109
|
+
*/
|
|
110
|
+
function toCollectionId(cwd, collectionDir, filePath) {
|
|
111
|
+
const relativePath = posix.normalize(
|
|
112
|
+
toPosixPath(relative(resolve(cwd, collectionDir), filePath)),
|
|
113
|
+
)
|
|
114
|
+
return relativePath.replace(/\.json$/, "")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {unknown} value
|
|
119
|
+
* @param {string} filePath
|
|
120
|
+
*/
|
|
121
|
+
function readRequiredImageReference(value, filePath) {
|
|
122
|
+
if (typeof value !== "string") {
|
|
123
|
+
throw new CliError(`Invalid "image" in "${filePath}": expected a string.`)
|
|
124
|
+
}
|
|
125
|
+
return value
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {unknown} value
|
|
130
|
+
* @param {string} filePath
|
|
131
|
+
*/
|
|
132
|
+
function readOptionalImageReference(value, filePath) {
|
|
133
|
+
if (value === undefined) {
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
if (typeof value !== "string") {
|
|
137
|
+
throw new CliError(
|
|
138
|
+
`Invalid "image" in "${filePath}": expected a string when provided.`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return value
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {unknown} value
|
|
146
|
+
* @param {string} filePath
|
|
147
|
+
* @returns {MediaContent[]}
|
|
148
|
+
*/
|
|
149
|
+
function readContentArray(value, filePath) {
|
|
150
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
151
|
+
throw new CliError(
|
|
152
|
+
`Invalid "content" in "${filePath}": expected a non-empty array.`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return value.map((item) => {
|
|
157
|
+
if (!isPlainObject(item)) {
|
|
158
|
+
throw new CliError(
|
|
159
|
+
`Invalid "content" in "${filePath}": expected array items to be objects.`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
if (item.type !== "upload" && item.type !== "link") {
|
|
163
|
+
throw new CliError(
|
|
164
|
+
`Invalid "content.type" in "${filePath}": expected "upload" or "link".`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
if (typeof item.url !== "string") {
|
|
168
|
+
throw new CliError(
|
|
169
|
+
`Invalid "content.url" in "${filePath}": expected a string.`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
type: item.type,
|
|
174
|
+
url: item.url,
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {unknown} value
|
|
181
|
+
*/
|
|
182
|
+
function isPlainObject(value) {
|
|
183
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} filePath
|
|
188
|
+
*/
|
|
189
|
+
function toPosixPath(filePath) {
|
|
190
|
+
return filePath.split("\\").join("/")
|
|
191
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { access, readdir, readFile, unlink } from "node:fs/promises"
|
|
4
|
+
import { join, posix, relative, resolve } from "node:path"
|
|
5
|
+
|
|
6
|
+
import { CliError } from "./cli-error.js"
|
|
7
|
+
|
|
8
|
+
const localJunkFileNames = new Set([".DS_Store"])
|
|
9
|
+
const publicFilesDir = "public/files"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{
|
|
13
|
+
* init: () => Promise<FileStorage>
|
|
14
|
+
* list: () => Promise<string[]>
|
|
15
|
+
* delete: (paths: string[]) => Promise<string[]>
|
|
16
|
+
* toPath: (url: string) => string|undefined
|
|
17
|
+
* }} FileStorage
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} cwd
|
|
22
|
+
* @returns {FileStorage}
|
|
23
|
+
*/
|
|
24
|
+
export function contentFiles(cwd) {
|
|
25
|
+
return files(cwd, {
|
|
26
|
+
displayPath: (path) =>
|
|
27
|
+
`/${toPosixPath(relative(resolve(cwd, "public"), resolve(cwd, path)))}`,
|
|
28
|
+
rootDir: publicFilesDir,
|
|
29
|
+
toAbsolutePath: toLocalContentFilePath,
|
|
30
|
+
toPath: (url) => (url.startsWith("/files/") ? url : undefined),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} cwd
|
|
36
|
+
* @param {{
|
|
37
|
+
* displayPath?: (path:string) => string
|
|
38
|
+
* ignoreJunk?: boolean
|
|
39
|
+
* rootDir: string
|
|
40
|
+
* toAbsolutePath?: (path:string) => string
|
|
41
|
+
* toPath?: (url:string) => string|undefined
|
|
42
|
+
* }} options
|
|
43
|
+
* @returns {FileStorage}
|
|
44
|
+
*/
|
|
45
|
+
export function files(cwd, options) {
|
|
46
|
+
const storage = {
|
|
47
|
+
async init() {
|
|
48
|
+
return storage
|
|
49
|
+
},
|
|
50
|
+
async list() {
|
|
51
|
+
return collectLocalFiles(resolve(cwd, options.rootDir), {
|
|
52
|
+
displayPath: options.displayPath ?? ((path) => path),
|
|
53
|
+
ignoreJunk: options.ignoreJunk ?? true,
|
|
54
|
+
rootDir: options.rootDir,
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
/**
|
|
58
|
+
* @param {string[]} paths
|
|
59
|
+
*/
|
|
60
|
+
async delete(paths) {
|
|
61
|
+
/** @type {string[]} */
|
|
62
|
+
const removed = []
|
|
63
|
+
for (const path of paths) {
|
|
64
|
+
try {
|
|
65
|
+
await unlink(
|
|
66
|
+
resolve(
|
|
67
|
+
cwd,
|
|
68
|
+
options.toAbsolutePath?.(path) ??
|
|
69
|
+
toLocalFilePath(options.rootDir, path),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
removed.push(path)
|
|
73
|
+
} catch {
|
|
74
|
+
// The caller prints per-file failures based on the returned paths.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return removed
|
|
78
|
+
},
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} url
|
|
81
|
+
*/
|
|
82
|
+
toPath(url) {
|
|
83
|
+
return options.toPath?.(url)
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
return storage
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} dirPath
|
|
91
|
+
*/
|
|
92
|
+
export async function jsonFiles(dirPath) {
|
|
93
|
+
if (!(await pathExists(dirPath))) {
|
|
94
|
+
return []
|
|
95
|
+
}
|
|
96
|
+
const files = await walkFiles(dirPath)
|
|
97
|
+
return files.filter((filePath) => filePath.endsWith(".json")).sort()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} filePath
|
|
102
|
+
*/
|
|
103
|
+
export async function readJsonFile(filePath) {
|
|
104
|
+
const text = await readFile(filePath, "utf8")
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text)
|
|
107
|
+
} catch {
|
|
108
|
+
throw new CliError(`Invalid JSON in "${filePath}".`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} filePath
|
|
114
|
+
*/
|
|
115
|
+
export async function pathExists(filePath) {
|
|
116
|
+
try {
|
|
117
|
+
await access(filePath)
|
|
118
|
+
return true
|
|
119
|
+
} catch {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} path
|
|
126
|
+
*/
|
|
127
|
+
export function toLocalContentDisplayPath(path) {
|
|
128
|
+
if (!path.startsWith("/files/")) {
|
|
129
|
+
return path
|
|
130
|
+
}
|
|
131
|
+
return toPosixPath(join(publicFilesDir, path.slice("/files/".length)))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} absoluteDir
|
|
136
|
+
* @param {{displayPath:(path:string)=>string, ignoreJunk:boolean, rootDir:string}} options
|
|
137
|
+
*/
|
|
138
|
+
async function collectLocalFiles(absoluteDir, options) {
|
|
139
|
+
if (!(await pathExists(absoluteDir))) {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
const files = await walkFiles(absoluteDir)
|
|
143
|
+
return files
|
|
144
|
+
.filter((filePath) =>
|
|
145
|
+
options.ignoreJunk
|
|
146
|
+
? !localJunkFileNames.has(posix.basename(filePath))
|
|
147
|
+
: true,
|
|
148
|
+
)
|
|
149
|
+
.map((filePath) =>
|
|
150
|
+
options.displayPath(
|
|
151
|
+
toPosixPath(join(options.rootDir, relative(absoluteDir, filePath))),
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
.sort()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {string} dirPath
|
|
159
|
+
*/
|
|
160
|
+
async function walkFiles(dirPath) {
|
|
161
|
+
/** @type {string[]} */
|
|
162
|
+
const files = []
|
|
163
|
+
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
const entryPath = join(dirPath, entry.name)
|
|
166
|
+
if (entry.isDirectory()) {
|
|
167
|
+
files.push(...(await walkFiles(entryPath)))
|
|
168
|
+
} else if (entry.isFile()) {
|
|
169
|
+
files.push(entryPath)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return files
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {string} path
|
|
177
|
+
*/
|
|
178
|
+
function toLocalContentFilePath(path) {
|
|
179
|
+
if (!path.startsWith("/files/")) {
|
|
180
|
+
throw new CliError(`Unexpected local content file path "${path}".`)
|
|
181
|
+
}
|
|
182
|
+
const relativePath = path.slice("/files/".length)
|
|
183
|
+
if (
|
|
184
|
+
!relativePath ||
|
|
185
|
+
relativePath.startsWith("/") ||
|
|
186
|
+
relativePath.includes("../")
|
|
187
|
+
) {
|
|
188
|
+
throw new CliError(`Unexpected local content file path "${path}".`)
|
|
189
|
+
}
|
|
190
|
+
return toPosixPath(join(publicFilesDir, relativePath))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} rootDir
|
|
195
|
+
* @param {string} path
|
|
196
|
+
*/
|
|
197
|
+
function toLocalFilePath(rootDir, path) {
|
|
198
|
+
if (!path.startsWith(`${rootDir}/`) && path !== rootDir) {
|
|
199
|
+
throw new CliError(`Unexpected local file path "${path}".`)
|
|
200
|
+
}
|
|
201
|
+
if (path.includes("../")) {
|
|
202
|
+
throw new CliError(`Unexpected local file path "${path}".`)
|
|
203
|
+
}
|
|
204
|
+
return path
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {string} filePath
|
|
209
|
+
*/
|
|
210
|
+
function toPosixPath(filePath) {
|
|
211
|
+
return filePath.split("\\").join("/")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {string} filePath
|
|
216
|
+
*/
|