-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathComponentV1.ts
More file actions
342 lines (290 loc) · 12 KB
/
Copy pathComponentV1.ts
File metadata and controls
342 lines (290 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
import { isMainThread } from 'node:worker_threads';
import fg from 'fast-glob';
import { Resources } from '../resources/Resources.ts';
import harperLogger from '../utility/logging/harper_logger.js';
import { resolveBaseURLPath } from './resolveBaseURLPath.ts';
import { deriveGlobOptions, FastGlobOptions, FilesOption } from './deriveGlobOptions.ts';
import { basename, join } from 'node:path';
import { readFile } from 'node:fs/promises';
import { deriveURLPath } from './deriveURLPath.ts';
import { scan } from 'micromatch';
interface ComponentV1Config {
files: string | string[] | FilesOption;
/** @deprecated */ path?: string;
urlPath?: string;
/** @deprecated */ root?: string;
[key: string]: any;
}
interface ComponentV1Module {
setupDirectory?: (urlPath: string, absolutePath: string, resources: Resources) => Promise<undefined | boolean>;
handleDirectory?: (urlPath: string, absolutePath: string, resources: Resources) => Promise<undefined | boolean>;
setupFile?: (contents: Buffer, urlPath: string, absolutePath: string, resources: Resources) => Promise<void>;
handleFile?: (contents: Buffer, urlPath: string, absolutePath: string, resources: Resources) => Promise<void>;
}
interface ComponentV1Details {
config: ComponentV1Config;
name: string;
directory: string;
module: ComponentV1Module;
resources: Resources;
}
export class ComponentV1 {
readonly config: ComponentV1Config;
readonly name: string;
readonly directory: string;
readonly module: Readonly<ComponentV1Module>;
readonly resources: Resources;
readonly globOptions: FastGlobOptions;
readonly patternBases: string[];
readonly baseURLPath: string;
constructor(options: ComponentV1Details) {
// TO DO: Unfortunately `readonly` is a TS only thing and doesn't actually enforce that these properties can't be modified.
// Freeze these things so they can't be changed. likely do this at the end of the constructor
this.config = options.config;
this.name = options.name;
this.directory = options.directory;
this.module = options.module;
this.resources = options.resources;
// Config option basic validation
if (
!isNonEmptyString(this.config.files) &&
!isArrayOfNonEmptyStrings(this.config.files) &&
!isObject(this.config.files)
) {
throw new InvalidFilesOptionError(this);
}
// Validating the `files` object
if (typeof this.config.files === 'object' && !Array.isArray(this.config.files)) {
if (
this.config.files.source === undefined ||
(!isArrayOfNonEmptyStrings(this.config.files.source) && !isNonEmptyString(this.config.files.source))
) {
throw new InvalidFilesSourceOptionError(this);
}
if (
this.config.files.only !== undefined &&
(typeof this.config.files.only !== 'string' ||
!['all', 'files', 'directories'].includes(this.config.files.only))
) {
throw new InvalidFilesOnlyOptionError(this);
}
if (
this.config.files.ignore !== undefined &&
!isArrayOfNonEmptyStrings(this.config.files.ignore) &&
!isNonEmptyString(this.config.files.ignore)
) {
throw new InvalidFileIgnoreOptionError(this);
}
}
// Validate the deprecated options too
if (this.config.root !== undefined && !isNonEmptyString(this.config.root)) {
throw new InvalidRootOptionError(this);
}
if (this.config.path !== undefined && !isNonEmptyString(this.config.path)) {
throw new InvalidPathOptionError(this);
}
// Handle deprecated `path` option
if (this.config.path) {
harperLogger.warn(`Resource extension 'path' option is deprecated. Please replace with 'urlPath'.`);
this.config.urlPath = this.config.path;
}
// Validate the `urlPath`
if (
this.config.urlPath !== undefined &&
(!isNonEmptyString(this.config.urlPath) ||
(typeof this.config.urlPath === 'string' && this.config.urlPath.includes('..')))
) {
throw new InvalidURLPathOptionError(this);
}
this.globOptions = deriveGlobOptions(this.config.files);
// Validate and transform glob patterns
this.globOptions.source = this.globOptions.source.map((pattern) => {
if (pattern.includes('..')) {
throw new InvalidGlobPattern(this, pattern);
}
if (pattern.startsWith('/')) {
harperLogger.warn(
`Leading '/' in 'files' glob pattern is deprecated. For backwards compatibility purposes, it is currently transformed to the relative path of the component, but in the future will result in an error. Paths are automatically derived from the root of the component directory. Please remove (e.g. '/web/*' -> 'web/*').`
);
pattern = pattern === '/' ? '.' : pattern.slice(1);
}
return pattern;
});
this.patternBases = this.globOptions.source.map((pattern) => scan(pattern).base);
this.baseURLPath = resolveBaseURLPath(this.name, this.config.urlPath);
}
}
export class ComponentV1ProcessingError extends Error {
constructor(message: string, component: ComponentV1Details) {
super(`Component ${component.name} (from ${basename(component.directory)}) ${message}`);
}
}
export class InvalidFilesOptionError extends ComponentV1ProcessingError {
constructor(component: ComponentV1Details) {
super(`'files' option must be a non-empty string, an array of non-empty strings, or an object.`, component);
}
}
export class InvalidFilesSourceOptionError extends ComponentV1ProcessingError {
constructor(component: ComponentV1Details) {
super(`'files' object must have a non-empty 'source' property.`, component);
}
}
export class InvalidFilesOnlyOptionError extends ComponentV1ProcessingError {
constructor(component: ComponentV1Details) {
super(`'files.only' option must be one of 'all', 'files', or 'directories'.`, component);
}
}
export class InvalidFileIgnoreOptionError extends ComponentV1ProcessingError {
constructor(component: ComponentV1Details) {
super(`'files.ignore' option must be a non-empty string or an array of non-empty strings.`, component);
}
}
export class InvalidGlobPattern extends ComponentV1ProcessingError {
constructor(component: ComponentV1Details, pattern: string) {
super(`'files' glob pattern must not contain '..'. Received: '${pattern}'`, component);
}
}
export class InvalidRootOptionError extends ComponentV1ProcessingError {
constructor(component: ComponentV1Details) {
super(
`deprecated 'root' option must be a non-empty string. Consider removing and updating 'files' glob pattern instead.`,
component
);
}
}
export class InvalidRootOptionUseError extends ComponentV1ProcessingError {
constructor(component: ComponentV1Details) {
super(
`the 'root' option is deprecated and only supported if 'files' is a singular, non-empty string. Please remove the 'root' option and modify the 'files' glob pattern instead.`,
component
);
}
}
export class InvalidPathOptionError extends ComponentV1ProcessingError {
constructor(component: ComponentV1Details) {
super(`deprecated 'path' option must be a non-empty string. Consider replacing with 'urlPath'.`, component);
}
}
export class InvalidURLPathOptionError extends ComponentV1ProcessingError {
constructor(component: ComponentV1Details) {
super(`'urlPath' option must be a non-empty string that must not contain '..'.`, component);
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim() !== '';
}
function isArrayOfNonEmptyStrings(value: unknown): value is string[] {
return Array.isArray(value) && value.length !== 0 && value.every((item) => isNonEmptyString(item));
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function handleRoots(component: ComponentV1) {
if (component.config.root) {
harperLogger.warn(
`Resource extension 'root' option is deprecated. Due to backwards compatibility reasons it does not act as assumed. The glob pattern will always be evaluated from the component directory root. The option is only used for the initial root directory handling. Please remove and modify the 'files' glob pattern instead.`
);
}
// For backwards compatibility, we need to evaluate the root path via the existing logic. This is only valid if `root` is defined, and `files` is a strings that doesn't contain `**/*`,
// And if that existing logic does not produce a reasonable root path to evaluate, we can consider the configure "new" and evaluate it based on a new process
let rootPaths: string[] = [];
if (component.config.root && typeof component.config.files !== 'string') {
throw new InvalidRootOptionUseError(component);
}
// This starts old root handling
let rootPath = component.config.root;
if (rootPath) {
// trim any leading slashes
if (rootPath.startsWith('/')) {
rootPath = rootPath.slice(1);
}
// add a trailing slash if it doesn't exist
if (!rootPath.endsWith('/')) {
rootPath += '/';
}
}
const pattern = component.config.files;
// This is still old root handling logic - operate only a singular pattern
if (typeof pattern === 'string' && !pattern.includes('**/*')) {
if (pattern.indexOf('/*') > -1) {
rootPath = pattern.slice(0, pattern.indexOf('/*') + 1);
} else if (pattern.indexOf('/') > -1) {
rootPath = pattern.slice(0, pattern.lastIndexOf('/') + 1);
}
}
if (rootPath) rootPaths.push(rootPath);
// If old handling did not result in a root path, now use the patternRoots derived from the processed glob patterns
if (rootPaths.length === 0) {
// Return early if we are only processing files
if (isObject(component.config.files) && component.config.files.only === 'files') {
return false;
}
rootPaths = component.patternBases;
}
let hasFunctionality: boolean | undefined = false;
for (const rootPath of rootPaths) {
const rootPathAbsolute = join(component.directory, rootPath);
if (isMainThread && component.module.setupDirectory) {
hasFunctionality = await component.module.setupDirectory(
component.baseURLPath,
rootPathAbsolute,
component.resources
);
}
if (component.resources.isWorker && component.module.handleDirectory) {
hasFunctionality = await component.module.handleDirectory(
component.baseURLPath,
rootPathAbsolute,
component.resources
);
}
}
return hasFunctionality;
}
/**
* Process a Resource Extension component by evaluating the files glob pattern
* and then calling the appropriate setup/handle functions.
*/
export async function processResourceExtensionComponent(component: ComponentV1) {
let hasFunctionality: boolean | undefined = false;
hasFunctionality = await handleRoots(component);
// Return early if roots were functional
if (hasFunctionality) return hasFunctionality;
const matches = await fg(component.globOptions.source, {
cwd: component.directory,
objectMode: true,
onlyFiles: component.globOptions.onlyFiles,
onlyDirectories: component.globOptions.onlyDirectories,
ignore: component.globOptions.ignore,
});
for (const entry of matches) {
const absolutePath = join(component.directory, entry.path);
if (entry.dirent.isDirectory()) {
const urlPath = deriveURLPath(component, entry.path, 'directory');
if (isMainThread && component.module.setupDirectory) {
await component.module.setupDirectory(urlPath, absolutePath, component.resources);
hasFunctionality = true;
}
if (component.resources.isWorker && component.module.handleDirectory) {
await component.module.handleDirectory(urlPath, absolutePath, component.resources);
hasFunctionality = true;
}
} else if (entry.dirent.isFile()) {
const urlPath = deriveURLPath(component, entry.path, 'file');
const contents = await readFile(absolutePath);
if (isMainThread && component.module.setupFile) {
await component.module.setupFile(contents, urlPath, absolutePath, component.resources);
hasFunctionality = true;
} else if (component.resources.isWorker && component.module.handleFile) {
await component.module.handleFile(contents, urlPath, absolutePath, component.resources);
hasFunctionality = true;
}
} else {
harperLogger.error(
`Entry received from glob pattern match for component ${component.name} is neither a file nor a directory:`,
entry
);
}
}
return hasFunctionality;
}