@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.
@@ -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
+ */