path-class 0.6.0 → 0.7.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.
@@ -1,7 +1,7 @@
1
1
  import { expect, spyOn, test } from "bun:test";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { Path } from ".";
4
+ import { Path, stringifyIfPath } from "./Path";
5
5
 
6
6
  test("constructor", async () => {
7
7
  expect(new Path("foo").path).toEqual("foo");
@@ -63,7 +63,7 @@ test("normalize", async () => {
63
63
  expect(new Path("//absolute////bar").path).toEqual("/absolute/bar");
64
64
  });
65
65
 
66
- test("join", async () => {
66
+ test(".join(…)", async () => {
67
67
  expect(new Path("foo").join("bar").path).toEqual("foo/bar");
68
68
  expect(new Path("foo/bar").join("bath", "kitchen/sink").path).toEqual(
69
69
  "foo/bar/bath/kitchen/sink",
@@ -161,14 +161,14 @@ test(".existsAsDir()", async () => {
161
161
  expect(await filePath.existsAsDir()).toBe(false);
162
162
  });
163
163
 
164
- test("mkdir (un-nested)", async () => {
164
+ test(".mkdir(…) (un-nested)", async () => {
165
165
  const dir = (await Path.makeTempDir()).join("mkdir-test");
166
166
  expect(await dir.exists()).toBe(false);
167
167
  await dir.mkdir();
168
168
  expect(await dir.exists()).toBe(true);
169
169
  });
170
170
 
171
- test("mkdir (nested)", async () => {
171
+ test(".mkdir(…) (nested)", async () => {
172
172
  const dir = (await Path.makeTempDir()).join("mkdir-test/nested");
173
173
  expect(await dir.exists()).toBe(false);
174
174
  expect(() => dir.mkdir({ recursive: false })).toThrow("no such file");
@@ -176,7 +176,21 @@ test("mkdir (nested)", async () => {
176
176
  expect(await dir.exists()).toBe(true);
177
177
  });
178
178
 
179
- test(".rename()", async () => {
179
+ test(".cp()", async () => {
180
+ const parentDir = await Path.makeTempDir();
181
+ const file1 = parentDir.join("file1.txt");
182
+ const file2 = parentDir.join("file2.txt");
183
+
184
+ await file1.write("hello world");
185
+ expect(await file1.exists()).toBe(true);
186
+ expect(await file2.exists()).toBe(false);
187
+
188
+ await file1.cp(file2);
189
+ expect(await file1.exists()).toBe(true);
190
+ expect(await file2.exists()).toBe(true);
191
+ });
192
+
193
+ test(".rename(…)", async () => {
180
194
  const parentDir = await Path.makeTempDir();
181
195
  const file1 = parentDir.join("file1.txt");
182
196
  const file2 = parentDir.join("file2.txt");
@@ -201,7 +215,7 @@ test(".makeTempDir(…)", async () => {
201
215
  expect(tempDir2.basename.path).toStartWith("foo");
202
216
  });
203
217
 
204
- test("rm (file)", async () => {
218
+ test(".rm(…) (file)", async () => {
205
219
  const file = (await Path.makeTempDir()).join("file.txt");
206
220
  await file.write("");
207
221
  expect(await file.existsAsFile()).toBe(true);
@@ -211,7 +225,7 @@ test("rm (file)", async () => {
211
225
  expect(async () => file.rm()).toThrowError(/ENOENT/);
212
226
  });
213
227
 
214
- test("rm (folder)", async () => {
228
+ test(".rm(…) (folder)", async () => {
215
229
  const tempDir = await Path.makeTempDir();
216
230
  const file = tempDir.join("file.txt");
217
231
  await file.write("");
@@ -223,7 +237,7 @@ test("rm (folder)", async () => {
223
237
  expect(async () => tempDir.rm()).toThrowError(/ENOENT/);
224
238
  });
225
239
 
226
- test("rm_rf (file)", async () => {
240
+ test(".rm_rf(…) (file)", async () => {
227
241
  const file = (await Path.makeTempDir()).join("file.txt");
228
242
  await file.write("");
229
243
  expect(await file.existsAsFile()).toBe(true);
@@ -234,7 +248,7 @@ test("rm_rf (file)", async () => {
234
248
  expect(await file.existsAsFile()).toBe(false);
235
249
  });
236
250
 
237
- test("rm_rf (folder)", async () => {
251
+ test(".rm_rf(…) (folder)", async () => {
238
252
  const tempDir = await Path.makeTempDir();
239
253
  await tempDir.join("file.txt").write("");
240
254
  expect(tempDir.path).toContain("/js-temp-");
@@ -245,7 +259,7 @@ test("rm_rf (folder)", async () => {
245
259
  expect(await tempDir.exists()).toBe(false);
246
260
  });
247
261
 
248
- test(".fileText()", async () => {
262
+ test(".readText()", async () => {
249
263
  const file = (await Path.makeTempDir()).join("file.txt");
250
264
  await file.write("hi");
251
265
  await file.write("bye");
@@ -254,7 +268,7 @@ test(".fileText()", async () => {
254
268
  expect(await readFile(file.path, "utf-8")).toBe("bye");
255
269
  });
256
270
 
257
- test(".fileJSON()", async () => {
271
+ test(".readJSON()", async () => {
258
272
  const file = (await Path.makeTempDir()).join("file.json");
259
273
  await file.write(JSON.stringify({ foo: "bar" }));
260
274
 
@@ -268,14 +282,14 @@ test(".fileJSON()", async () => {
268
282
  test(".write(…)", async () => {
269
283
  const tempDir = await Path.makeTempDir();
270
284
  const file = tempDir.join("file.json");
271
- await file.write("foo");
285
+ expect(await file.write("foo")).toBe(file);
272
286
 
273
287
  expect(await readFile(join(tempDir.path, "./file.json"), "utf-8")).toEqual(
274
288
  "foo",
275
289
  );
276
290
 
277
291
  const file2 = tempDir.join("nested/file2.json");
278
- await file2.write("bar");
292
+ expect(await file2.write("bar")).toBe(file2);
279
293
  expect(
280
294
  await readFile(join(tempDir.path, "./nested/file2.json"), "utf-8"),
281
295
  ).toEqual("bar");
@@ -283,7 +297,7 @@ test(".write(…)", async () => {
283
297
 
284
298
  test(".writeJSON(…)", async () => {
285
299
  const file = (await Path.makeTempDir()).join("file.json");
286
- await file.writeJSON({ foo: "bar" });
300
+ expect(await file.writeJSON({ foo: "bar" })).toBe(file);
287
301
 
288
302
  expect(await file.readJSON()).toEqual<Record<string, string>>({ foo: "bar" });
289
303
  });
@@ -296,23 +310,37 @@ test(".readDir(…)", async () => {
296
310
  const contentsAsStrings = await dir.readDir();
297
311
  expect(new Set(contentsAsStrings)).toEqual(new Set(["file.txt", "dir"]));
298
312
 
299
- // const contentsAsEntries = await dir.readDir({ withFileTypes: true });
313
+ const contentsAsEntries = await dir.readDir({ withFileTypes: true });
314
+ expect(contentsAsEntries.map((entry) => entry.name)).toEqual([
315
+ "file.txt",
316
+ "dir",
317
+ ]);
300
318
  });
301
319
 
302
- test("homedir", async () => {
320
+ test(".homedir", async () => {
303
321
  expect(Path.homedir.path).toEqual("/mock/home/dir");
304
322
  });
305
323
 
306
- test("XDG", async () => {
324
+ test(".xdg", async () => {
307
325
  expect(Path.xdg.cache.path).toEqual("/mock/home/dir/.cache");
308
326
  expect(Path.xdg.config.path).toEqual("/xdg/config");
309
327
  expect(Path.xdg.data.path).toEqual("/mock/home/dir/.local/share");
310
328
  expect(Path.xdg.state.path).toEqual("/mock/home/dir/.local/state");
329
+ expect(Path.xdg.runtime).toBeUndefined();
330
+ expect(Path.xdg.runtimeWithStateFallback.path).toEqual(
331
+ "/mock/home/dir/.local/state",
332
+ );
311
333
  });
312
334
 
313
335
  const spy = spyOn(console, "log");
314
336
 
315
- test("debugPrint", async () => {
337
+ test(".debugPrint(…)", async () => {
316
338
  Path.homedir.debugPrint("foo");
317
339
  expect(spy.mock.calls).toEqual([["foo"], ["/mock/home/dir"]]);
318
340
  });
341
+
342
+ test(".stringifyIfPath(…)", async () => {
343
+ expect(stringifyIfPath(Path.homedir)).toBe("/mock/home/dir");
344
+ expect(stringifyIfPath("/mock/home/dir")).toBe("/mock/home/dir");
345
+ expect(stringifyIfPath(4)).toBe(4);
346
+ });
package/src/Path.ts ADDED
@@ -0,0 +1,446 @@
1
+ import type { Abortable } from "node:events";
2
+ import type { Dirent, ObjectEncodingOptions, OpenMode } from "node:fs";
3
+ import {
4
+ cp,
5
+ mkdir,
6
+ mkdtemp,
7
+ readdir,
8
+ readFile,
9
+ rename,
10
+ rm,
11
+ stat,
12
+ writeFile,
13
+ } from "node:fs/promises";
14
+ import { homedir, tmpdir } from "node:os";
15
+ import { basename, dirname, extname, join } from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+ import {
18
+ xdgCache,
19
+ xdgConfig,
20
+ xdgData,
21
+ xdgRuntime,
22
+ xdgState,
23
+ } from "xdg-basedir";
24
+
25
+ // Modifying the type of `readdir(…)` from `node:fs/promises` to remove the
26
+ // first parameter is difficult, if not impossible. So we give up and duplicate
27
+ // the types manually. This ensures ergonomic types, such as an inferred return
28
+ // type of `string[]` when `options` is not passed.
29
+
30
+ declare function readDirType(
31
+ options?:
32
+ | (ObjectEncodingOptions & {
33
+ withFileTypes?: false | undefined;
34
+ recursive?: boolean | undefined;
35
+ })
36
+ | BufferEncoding
37
+ | null,
38
+ ): Promise<string[]>;
39
+
40
+ declare function readDirType(
41
+ options:
42
+ | {
43
+ encoding: "buffer";
44
+ withFileTypes?: false | undefined;
45
+ recursive?: boolean | undefined;
46
+ }
47
+ | "buffer",
48
+ ): Promise<Buffer[]>;
49
+
50
+ declare function readDirType(
51
+ options?:
52
+ | (ObjectEncodingOptions & {
53
+ withFileTypes?: false | undefined;
54
+ recursive?: boolean | undefined;
55
+ })
56
+ | BufferEncoding
57
+ | null,
58
+ ): Promise<string[] | Buffer[]>;
59
+
60
+ declare function readDirType(
61
+ options: ObjectEncodingOptions & {
62
+ withFileTypes: true;
63
+ recursive?: boolean | undefined;
64
+ },
65
+ ): Promise<Dirent[]>;
66
+
67
+ declare function readDirType(options: {
68
+ encoding: "buffer";
69
+ withFileTypes: true;
70
+ recursive?: boolean | undefined;
71
+ }): Promise<Dirent<Buffer>[]>;
72
+
73
+ declare function readFileType(
74
+ options?:
75
+ | ({
76
+ encoding?: null | undefined;
77
+ flag?: OpenMode | undefined;
78
+ } & Abortable)
79
+ | null,
80
+ ): Promise<Buffer>;
81
+ declare function readFileType(
82
+ options:
83
+ | ({
84
+ encoding: BufferEncoding;
85
+ flag?: OpenMode | undefined;
86
+ } & Abortable)
87
+ | BufferEncoding,
88
+ ): Promise<string>;
89
+ declare function readFileType(
90
+ options?:
91
+ | (ObjectEncodingOptions &
92
+ Abortable & {
93
+ flag?: OpenMode | undefined;
94
+ })
95
+ | BufferEncoding
96
+ | null,
97
+ ): Promise<string | Buffer>;
98
+
99
+ export class Path {
100
+ // @ts-expect-error ts(2564): False positive. https://github.com/microsoft/TypeScript/issues/32194
101
+ #path: string;
102
+ /**
103
+ * If `path` is a string starting with `file:///`, it will be parsed as a file URL.
104
+ */
105
+ constructor(path: string | URL | Path) {
106
+ this.#setNormalizedPath(Path.#pathlikeToString(path));
107
+ }
108
+
109
+ /**
110
+ * Similar to `new URL(path, base)`, but accepting and returning `Path` objects.
111
+ * Note that `base` must be one of:
112
+ *
113
+ * - a valid second argument to `new URL(…)`.
114
+ * - a `Path` representing an absolute path.
115
+ *
116
+ */
117
+ static resolve(path: string | URL | Path, base: string | URL | Path): Path {
118
+ const baseURL = (() => {
119
+ if (!(base instanceof Path)) {
120
+ return base;
121
+ }
122
+ if (!base.isAbsolutePath()) {
123
+ throw new Error(
124
+ "The `base` arg to `Path.resolve(…)` must be an absolute path.",
125
+ );
126
+ }
127
+ return pathToFileURL(base.#path);
128
+ })();
129
+ return new Path(new URL(Path.#pathlikeToString(path), baseURL));
130
+ }
131
+
132
+ static #pathlikeToString(path: string | URL | Path): string {
133
+ if (path instanceof Path) {
134
+ return path.#path;
135
+ }
136
+ if (path instanceof URL) {
137
+ return fileURLToPath(path);
138
+ }
139
+ if (typeof path === "string") {
140
+ // TODO: allow turning off this heuristic?
141
+ if (path.startsWith("file:///")) {
142
+ return fileURLToPath(path);
143
+ }
144
+ return path;
145
+ }
146
+ throw new Error("Invalid path");
147
+ }
148
+
149
+ #setNormalizedPath(path: string): void {
150
+ this.#path = join(path);
151
+ }
152
+
153
+ isAbsolutePath(): boolean {
154
+ return this.#path.startsWith("/");
155
+ }
156
+
157
+ toFileURL(): URL {
158
+ if (!this.isAbsolutePath()) {
159
+ throw new Error(
160
+ "Tried to convert to file URL when the path is not absolute.",
161
+ );
162
+ }
163
+ return pathToFileURL(this.#path);
164
+ }
165
+
166
+ /**
167
+ * The `Path` can have a trailing slash, indicating that it represents a
168
+ * directory. (If there is no trailing slash, it can represent either a file
169
+ * or a directory.)
170
+ *
171
+ * Some operations will refuse to treat a directory path as a file path. This
172
+ * function identifies such paths.
173
+ */
174
+ hasTrailingSlash(): boolean {
175
+ // TODO: handle Windows semantically
176
+ return this.#path.endsWith("/");
177
+ }
178
+
179
+ /**
180
+ * Same as `.toString()`, but more concise.
181
+ */
182
+ get path() {
183
+ return this.#path;
184
+ }
185
+
186
+ toString(): string {
187
+ return this.#path;
188
+ }
189
+
190
+ /** Constructs a new path by appending the given path segments.
191
+ * This follows `node` semantics for absolute paths: leading slashes in the given descendant segments are ignored.
192
+ */
193
+ join(...segments: (string | Path)[]): Path {
194
+ const segmentStrings = segments.map((segment) =>
195
+ segment instanceof Path ? segment.path : segment,
196
+ );
197
+ return new Path(join(this.#path, ...segmentStrings));
198
+ }
199
+
200
+ extendBasename(suffix: string): Path {
201
+ const joinedSuffix = join(suffix);
202
+ if (joinedSuffix !== basename(joinedSuffix)) {
203
+ throw new Error("Invalid suffix to extend file name.");
204
+ }
205
+ // TODO: join basename and dirname instead?
206
+ return new Path(this.#path + joinedSuffix);
207
+ }
208
+
209
+ get parent(): Path {
210
+ return new Path(dirname(this.#path));
211
+ }
212
+
213
+ // Normally I'd stick with `node`'s name, but I think `.dirname` is a
214
+ // particularly poor name. So we support `.dirname` for discovery but mark it
215
+ // as deprecated, even if it will never be removed.
216
+ /** @deprecated Alias for `.parent`. */
217
+ get dirname(): Path {
218
+ return this.parent;
219
+ }
220
+
221
+ get basename(): Path {
222
+ return new Path(basename(this.#path));
223
+ }
224
+
225
+ get extension(): string {
226
+ mustNotHaveTrailingSlash(this);
227
+ return extname(this.#path);
228
+ }
229
+
230
+ // Normally I'd stick with `node`'s name, but I think `.extname` is a
231
+ // particularly poor name. So we support `.extname` for discovery but mark it
232
+ // as deprecated, even if it will never be removed.
233
+ /** @deprecated Alias for `.extension`. */
234
+ get extname(): string {
235
+ return this.extension;
236
+ }
237
+
238
+ // TODO: find a neat way to dedup with the sync version?
239
+ async exists(constraints?: {
240
+ mustBe: "file" | "directory";
241
+ }): Promise<boolean> {
242
+ let stats: Awaited<ReturnType<typeof stat>>;
243
+ try {
244
+ stats = await stat(this.#path);
245
+ // biome-ignore lint/suspicious/noExplicitAny: TypeScript limitation
246
+ } catch (e: any) {
247
+ if (e.code === "ENOENT") {
248
+ return false;
249
+ }
250
+ throw e;
251
+ }
252
+ if (!constraints?.mustBe) {
253
+ return true;
254
+ }
255
+ switch (constraints?.mustBe) {
256
+ case "file": {
257
+ mustNotHaveTrailingSlash(this);
258
+ if (stats.isFile()) {
259
+ return true;
260
+ }
261
+ throw new Error(`Path exists but is not a file: ${this.#path}`);
262
+ }
263
+ case "directory": {
264
+ if (stats.isDirectory()) {
265
+ return true;
266
+ }
267
+ throw new Error(`Path exists but is not a directory: ${this.#path}`);
268
+ }
269
+ default: {
270
+ throw new Error("Invalid path type constraint");
271
+ }
272
+ }
273
+ }
274
+
275
+ async existsAsFile(): Promise<boolean> {
276
+ return this.exists({ mustBe: "file" });
277
+ }
278
+
279
+ async existsAsDir(): Promise<boolean> {
280
+ return this.exists({ mustBe: "directory" });
281
+ }
282
+
283
+ // I don't think `mkdir` is a great name, but it does match the
284
+ // well-established canonical commandline name. So in this case we keep the
285
+ // awkward abbreviation.
286
+ /** Defaults to `recursive: true`. */
287
+ async mkdir(options?: Parameters<typeof mkdir>[1]): Promise<Path> {
288
+ const optionsObject = (() => {
289
+ if (typeof options === "string" || typeof options === "number") {
290
+ return { mode: options };
291
+ }
292
+ return options ?? {};
293
+ })();
294
+ await mkdir(this.#path, { recursive: true, ...optionsObject });
295
+ return this;
296
+ }
297
+
298
+ // TODO: check idempotency semantics when the destination exists and is a folder.
299
+ /** Returns the destination path. */
300
+ async cp(
301
+ destination: string | URL | Path,
302
+ options?: Parameters<typeof cp>[2],
303
+ ): Promise<Path> {
304
+ await cp(this.#path, new Path(destination).#path, options);
305
+ return new Path(destination);
306
+ }
307
+
308
+ // TODO: check idempotency semantics when the destination exists and is a folder.
309
+ async rename(destination: string | URL | Path): Promise<void> {
310
+ await rename(this.#path, new Path(destination).#path);
311
+ }
312
+
313
+ /** Create a temporary dir inside the global temp dir for the current user. */
314
+ static async makeTempDir(prefix?: string): Promise<Path> {
315
+ return new Path(
316
+ await mkdtemp(new Path(tmpdir()).join(prefix ?? "js-temp-").toString()),
317
+ );
318
+ }
319
+
320
+ async rm(options?: Parameters<typeof rm>[1]): Promise<void> {
321
+ await rm(this.#path, options);
322
+ }
323
+
324
+ /**
325
+ * Equivalent to:
326
+ *
327
+ * .rm({ recursive: true, force: true, ...(options ?? {}) })
328
+ *
329
+ */
330
+ async rm_rf(options?: Parameters<typeof rm>[1]): Promise<void> {
331
+ await this.rm({ recursive: true, force: true, ...(options ?? {}) });
332
+ }
333
+
334
+ read: typeof readFileType = (options) =>
335
+ // biome-ignore lint/suspicious/noExplicitAny: Needed to wrangle the types.
336
+ readFile(this.#path, options as any) as any;
337
+
338
+ async readText(): Promise<string> {
339
+ return readFile(this.#path, "utf-8");
340
+ }
341
+
342
+ async readJSON<T>(): Promise<T> {
343
+ return JSON.parse(await this.readText());
344
+ }
345
+
346
+ /** Creates intermediate directories if they do not exist.
347
+ *
348
+ * Returns the original `Path` (for chaining).
349
+ */
350
+ async write(
351
+ data: Parameters<typeof writeFile>[1],
352
+ options?: Parameters<typeof writeFile>[2],
353
+ ): Promise<Path> {
354
+ await this.parent.mkdir();
355
+ await writeFile(this.#path, data, options);
356
+ return this;
357
+ }
358
+
359
+ /**
360
+ * If only `data` is provided, this is equivalent to:
361
+ *
362
+ * .write(JSON.stringify(data, null, " "));
363
+ *
364
+ * `replacer` and `space` can also be specified, making this equivalent to:
365
+ *
366
+ * .write(JSON.stringify(data, replacer, space));
367
+ *
368
+ * Returns the original `Path` (for chaining).
369
+ */
370
+ async writeJSON<T>(
371
+ data: T,
372
+ replacer: Parameters<typeof JSON.stringify>[1] = null,
373
+ space: Parameters<typeof JSON.stringify>[2] = " ",
374
+ ): Promise<Path> {
375
+ await this.write(JSON.stringify(data, replacer, space));
376
+ return this;
377
+ }
378
+
379
+ // Normally we'd add a `@deprecated` alias named `.readdir`, but that would
380
+ // differ only by capitalization of a single non-leading character. This can
381
+ // be a bit confusing, especially when autocompleting. So for this function in
382
+ // particular we don't include an alias.
383
+ readDir: typeof readDirType = (options) =>
384
+ // biome-ignore lint/suspicious/noExplicitAny: Needed to wrangle the types.
385
+ readdir(this.#path, options as any) as any;
386
+
387
+ static get homedir(): Path {
388
+ return new Path(homedir());
389
+ }
390
+
391
+ static xdg = {
392
+ cache: new Path(xdgCache ?? Path.homedir.join(".cache")),
393
+ config: new Path(xdgConfig ?? Path.homedir.join(".config")),
394
+ data: new Path(xdgData ?? Path.homedir.join(".local/share")),
395
+ state: new Path(xdgState ?? Path.homedir.join(".local/state")),
396
+ /**
397
+ * {@link Path.xdg.runtime} does not have a default value. Consider
398
+ * {@link Path.xdg.runtimeWithStateFallback} if you need a fallback but do not have a particular fallback in mind.
399
+ */
400
+ runtime: xdgRuntime ? new Path(xdgRuntime) : undefined,
401
+ runtimeWithStateFallback: xdgRuntime
402
+ ? new Path(xdgRuntime)
403
+ : new Path(xdgState ?? Path.homedir.join(".local/state")),
404
+ };
405
+
406
+ /** Chainable function to print the path. Prints the same as:
407
+ *
408
+ * if (args.length > 0) {
409
+ * console.log(...args);
410
+ * }
411
+ * console.log(this.path);
412
+ *
413
+ */
414
+ // biome-ignore lint/suspicious/noExplicitAny: This is the correct type, based on `console.log(…)`.
415
+ debugPrint(...args: any[]): Path {
416
+ if (args.length > 0) {
417
+ console.log(...args);
418
+ }
419
+ console.log(this.#path);
420
+ return this;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * This function is useful to serialize any `Path`s in a structure to pass on to
426
+ * functions that do not know about the `Path` class, e.g.
427
+ *
428
+ * function process(args: (string | Path)[]) {
429
+ * const argsAsStrings = args.map(stringifyIfPath);
430
+ * }
431
+ *
432
+ */
433
+ export function stringifyIfPath<T>(value: T | Path): T | string {
434
+ if (value instanceof Path) {
435
+ return value.toString();
436
+ }
437
+ return value;
438
+ }
439
+
440
+ export function mustNotHaveTrailingSlash(path: Path): void {
441
+ if (path.hasTrailingSlash()) {
442
+ throw new Error(
443
+ "Path ends with a slash, which cannot be treated as a file.",
444
+ );
445
+ }
446
+ }