@bgord/tools 0.12.12 → 0.12.14

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,3 @@
1
+ import { z } from "zod/v4";
2
+ export declare const BasenameSchema: z.core.$ZodBranded<z.ZodString, "basename">;
3
+ export type BasenameType = z.infer<typeof BasenameSchema>;
@@ -0,0 +1,16 @@
1
+ import { z } from "zod/v4";
2
+ export const BasenameSchema = z
3
+ .string()
4
+ .trim()
5
+ .min(1, "basename_empty")
6
+ .max(128, "basename_too_long")
7
+ .refine((s) => !/[/\\]/.test(s), "basename_slashes_forbidden")
8
+ // biome-ignore lint: lint/suspicious/noControlCharactersInRegex
9
+ .refine((s) => !/[\u0000-\u001F\u007F]/.test(s), "basename_control_chars_forbidden")
10
+ // check dot-segments FIRST so "." / ".." get the intended error
11
+ .refine((s) => s !== "." && s !== "..", "basename_dot_segments_forbidden")
12
+ // then disallow any other dotfile (".env", ".gitignore", etc.)
13
+ .refine((s) => !s.startsWith("."), "basename_dotfiles_forbidden")
14
+ .refine((s) => !s.endsWith("."), "basename_trailing_dot_forbidden")
15
+ .regex(/^[A-Za-z0-9._-]+$/, "basename_bad_chars")
16
+ .brand("basename");
@@ -0,0 +1,3 @@
1
+ import { z } from "zod/v4";
2
+ export declare const DirectoryPathAbsoluteSchema: z.core.$ZodBranded<z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodTransform<string, string>>, "absolute_directory_path">;
3
+ export type DirectoryPathAbsoluteType = z.infer<typeof DirectoryPathAbsoluteSchema>;
@@ -0,0 +1,17 @@
1
+ import { z } from "zod/v4";
2
+ export const DirectoryPathAbsoluteSchema = z
3
+ .string()
4
+ .trim()
5
+ .refine((value) => value.startsWith("/"), "abs_dir_must_start_with_slash")
6
+ .refine((value) => !value.includes("\\"), "abs_dir_backslash_forbidden")
7
+ // biome-ignore lint: lint/suspicious/noControlCharactersInRegex
8
+ .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), "abs_dir_control_chars_forbidden")
9
+ .transform((value) => value.replace(/\/{2,}/g, "/"))
10
+ .transform((value) => (value !== "/" && value.endsWith("/") ? value.slice(0, -1) : value))
11
+ .refine((value) => {
12
+ if (value === "/")
13
+ return true;
14
+ const segments = value.slice(1).split("/");
15
+ return segments.every((segment) => segment.length > 0 && /^[A-Za-z0-9._-]+$/.test(segment) && segment !== "." && segment !== "..");
16
+ }, "abs_dir_bad_segments")
17
+ .brand("absolute_directory_path");
@@ -0,0 +1,3 @@
1
+ import { z } from "zod/v4";
2
+ export declare const DirectoryPathRelativeSchema: z.core.$ZodBranded<z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodTransform<string, string>>, "relative_directory_path">;
3
+ export type DirectoryPathRelativeType = z.infer<typeof DirectoryPathRelativeSchema>;
@@ -0,0 +1,15 @@
1
+ import { z } from "zod/v4";
2
+ export const DirectoryPathRelativeSchema = z
3
+ .string()
4
+ .trim()
5
+ .refine((value) => !value.startsWith("/"), "rel_dir_must_not_start_with_slash")
6
+ .refine((value) => !value.includes("\\"), "rel_dir_backslash_forbidden")
7
+ // biome-ignore lint: lint/suspicious/noControlCharactersInRegex
8
+ .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), "rel_dir_control_chars_forbidden")
9
+ .transform((value) => value.replace(/\/{2,}/g, "/"))
10
+ .transform((value) => value.replace(/^\/+|\/+$/g, ""))
11
+ .refine((value) => value.length > 0, "rel_dir_empty")
12
+ .refine((value) => value
13
+ .split("/")
14
+ .every((segment) => /^[A-Za-z0-9._-]+$/.test(segment) && segment !== "." && segment !== ".."), "rel_dir_bad_segments")
15
+ .brand("relative_directory_path");
@@ -0,0 +1,3 @@
1
+ import { z } from "zod/v4";
2
+ export declare const ExtensionSchema: z.core.$ZodBranded<z.ZodPipe<z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodTransform<string, string>>, z.ZodString>, "extension">;
3
+ export type ExtensionType = z.infer<typeof ExtensionSchema>;
@@ -0,0 +1,12 @@
1
+ import { z } from "zod/v4";
2
+ export const ExtensionSchema = z
3
+ .string()
4
+ .trim()
5
+ .transform((value) => (value.startsWith(".") ? value.slice(1) : value))
6
+ .transform((value) => value.toLowerCase())
7
+ .pipe(z
8
+ .string()
9
+ .min(1, "extension_empty")
10
+ .max(16, "extension_too_long")
11
+ .regex(/^[a-z0-9]+$/, "extension_bad_chars"))
12
+ .brand("extension");
@@ -0,0 +1,29 @@
1
+ import { type DirectoryPathAbsoluteType } from "./directory-path-absolute.vo";
2
+ import { type DirectoryPathRelativeType } from "./directory-path-relative.vo";
3
+ import type { Filename } from "./filename.vo";
4
+ export declare class RelativeFilePath {
5
+ private readonly directory;
6
+ private readonly filename;
7
+ private constructor();
8
+ static fromParts(directoryCandidate: string, filename: Filename): RelativeFilePath;
9
+ static fromPartsSafe(directory: DirectoryPathRelativeType, filename: Filename): RelativeFilePath;
10
+ get(): string;
11
+ getDirectory(): DirectoryPathRelativeType;
12
+ getFilename(): Filename;
13
+ withDirectoryRelative(newDirectory: DirectoryPathRelativeType): RelativeFilePath;
14
+ withFilename(newFilename: Filename): RelativeFilePath;
15
+ toAbsolute(newDirectory: DirectoryPathAbsoluteType): AbsoluteFilePath;
16
+ }
17
+ export declare class AbsoluteFilePath {
18
+ private readonly directory;
19
+ private readonly filename;
20
+ private constructor();
21
+ static fromParts(directoryCandidate: string, filename: Filename): AbsoluteFilePath;
22
+ static fromPartsSafe(directory: DirectoryPathAbsoluteType, filename: Filename): AbsoluteFilePath;
23
+ get(): string;
24
+ getDirectory(): DirectoryPathAbsoluteType;
25
+ getFilename(): Filename;
26
+ withDirectoryAbsolute(newDirectory: DirectoryPathAbsoluteType): AbsoluteFilePath;
27
+ withFilename(newFilename: Filename): AbsoluteFilePath;
28
+ toRelative(newDirectory: DirectoryPathRelativeType): RelativeFilePath;
29
+ }
@@ -0,0 +1,70 @@
1
+ import { DirectoryPathAbsoluteSchema } from "./directory-path-absolute.vo";
2
+ import { DirectoryPathRelativeSchema } from "./directory-path-relative.vo";
3
+ export class RelativeFilePath {
4
+ directory;
5
+ filename;
6
+ constructor(directory, filename) {
7
+ this.directory = directory;
8
+ this.filename = filename;
9
+ }
10
+ static fromParts(directoryCandidate, filename) {
11
+ const directory = DirectoryPathRelativeSchema.parse(directoryCandidate);
12
+ return new RelativeFilePath(directory, filename);
13
+ }
14
+ static fromPartsSafe(directory, filename) {
15
+ return new RelativeFilePath(directory, filename);
16
+ }
17
+ get() {
18
+ return `${this.directory}/${this.filename.get()}`;
19
+ }
20
+ getDirectory() {
21
+ return this.directory;
22
+ }
23
+ getFilename() {
24
+ return this.filename;
25
+ }
26
+ withDirectoryRelative(newDirectory) {
27
+ return new RelativeFilePath(newDirectory, this.filename);
28
+ }
29
+ withFilename(newFilename) {
30
+ return new RelativeFilePath(this.directory, newFilename);
31
+ }
32
+ toAbsolute(newDirectory) {
33
+ return AbsoluteFilePath.fromPartsSafe(newDirectory, this.filename);
34
+ }
35
+ }
36
+ export class AbsoluteFilePath {
37
+ directory;
38
+ filename;
39
+ constructor(directory, filename) {
40
+ this.directory = directory;
41
+ this.filename = filename;
42
+ }
43
+ static fromParts(directoryCandidate, filename) {
44
+ const directory = DirectoryPathAbsoluteSchema.parse(directoryCandidate);
45
+ return new AbsoluteFilePath(directory, filename);
46
+ }
47
+ static fromPartsSafe(directory, filename) {
48
+ return new AbsoluteFilePath(directory, filename);
49
+ }
50
+ get() {
51
+ if (this.directory === "/")
52
+ return `/${this.filename.get()}`;
53
+ return `${this.directory}/${this.filename.get()}`;
54
+ }
55
+ getDirectory() {
56
+ return this.directory;
57
+ }
58
+ getFilename() {
59
+ return this.filename;
60
+ }
61
+ withDirectoryAbsolute(newDirectory) {
62
+ return new AbsoluteFilePath(newDirectory, this.filename);
63
+ }
64
+ withFilename(newFilename) {
65
+ return new AbsoluteFilePath(this.directory, newFilename);
66
+ }
67
+ toRelative(newDirectory) {
68
+ return RelativeFilePath.fromPartsSafe(newDirectory, this.filename);
69
+ }
70
+ }
@@ -0,0 +1,6 @@
1
+ import { z } from "zod/v4";
2
+ export declare const FilenameFromStringSchema: z.ZodPipe<z.ZodString, z.ZodTransform<{
3
+ basename: string & z.core.$brand<"basename">;
4
+ extension: string & z.core.$brand<"extension">;
5
+ }, string>>;
6
+ export type FilenameFromString = z.infer<typeof FilenameFromStringSchema>;
@@ -0,0 +1,17 @@
1
+ import { z } from "zod/v4";
2
+ import { BasenameSchema } from "./basename.vo";
3
+ import { ExtensionSchema } from "./extension.vo";
4
+ export const FilenameFromStringSchema = z
5
+ .string()
6
+ .trim()
7
+ .refine((string) => {
8
+ const index = string.lastIndexOf(".");
9
+ return index > 0 && index < string.length - 1;
10
+ }, "filename_invalid")
11
+ // split and validate parts using existing schemas
12
+ .transform((string) => {
13
+ const index = string.lastIndexOf(".");
14
+ const base = BasenameSchema.parse(string.slice(0, index));
15
+ const extension = ExtensionSchema.parse(string.slice(index + 1));
16
+ return { basename: base, extension: extension };
17
+ });
@@ -0,0 +1,3 @@
1
+ import { z } from "zod/v4";
2
+ export declare const FilenameSuffixSchema: z.core.$ZodBranded<z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>, "basename_suffix">;
3
+ export type FilenameSuffixSchemaType = z.infer<typeof FilenameSuffixSchema>;
@@ -0,0 +1,7 @@
1
+ import { z } from "zod/v4";
2
+ export const FilenameSuffixSchema = z
3
+ .string()
4
+ .trim()
5
+ .transform((value) => value.replace(/[^A-Za-z0-9_-]/g, ""))
6
+ .pipe(z.string().max(32, "suffix_too_long"))
7
+ .brand("basename_suffix");
@@ -0,0 +1,18 @@
1
+ import { type BasenameType } from "./basename.vo";
2
+ import { type ExtensionType } from "./extension.vo";
3
+ import { type FilenameSuffixSchemaType } from "./filename-suffix.vo";
4
+ export declare class Filename {
5
+ private readonly basename;
6
+ private readonly extension;
7
+ private constructor();
8
+ static fromParts(basename: string, extension: string): Filename;
9
+ static fromPartsSafe(basename: BasenameType, extension: ExtensionType): Filename;
10
+ static fromString(candidate: string): Filename;
11
+ get(): string;
12
+ getBasename(): BasenameType;
13
+ getExtension(): ExtensionType;
14
+ withExtension(extension: ExtensionType): Filename;
15
+ withBasename(basename: BasenameType): Filename;
16
+ withSuffix(candidate: string): Filename;
17
+ withSuffixSafe(suffix: FilenameSuffixSchemaType): Filename;
18
+ }
@@ -0,0 +1,46 @@
1
+ import { BasenameSchema } from "./basename.vo";
2
+ import { ExtensionSchema } from "./extension.vo";
3
+ import { FilenameFromStringSchema } from "./filename-from-string.vo";
4
+ import { FilenameSuffixSchema } from "./filename-suffix.vo";
5
+ export class Filename {
6
+ basename;
7
+ extension;
8
+ constructor(basename, extension) {
9
+ this.basename = basename;
10
+ this.extension = extension;
11
+ }
12
+ static fromParts(basename, extension) {
13
+ return new Filename(BasenameSchema.parse(basename), ExtensionSchema.parse(extension));
14
+ }
15
+ static fromPartsSafe(basename, extension) {
16
+ return new Filename(basename, extension);
17
+ }
18
+ static fromString(candidate) {
19
+ const { basename, extension } = FilenameFromStringSchema.parse(candidate);
20
+ return new Filename(basename, extension);
21
+ }
22
+ get() {
23
+ return `${this.basename}.${this.extension}`;
24
+ }
25
+ getBasename() {
26
+ return this.basename;
27
+ }
28
+ getExtension() {
29
+ return this.extension;
30
+ }
31
+ withExtension(extension) {
32
+ return new Filename(this.basename, extension);
33
+ }
34
+ withBasename(basename) {
35
+ return new Filename(basename, this.extension);
36
+ }
37
+ withSuffix(candidate) {
38
+ const suffix = FilenameSuffixSchema.parse(candidate);
39
+ const basename = BasenameSchema.parse(`${this.basename}${suffix}`);
40
+ return new Filename(basename, this.extension);
41
+ }
42
+ withSuffixSafe(suffix) {
43
+ const basename = BasenameSchema.parse(`${this.basename}${suffix}`);
44
+ return new Filename(basename, this.extension);
45
+ }
46
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./api-key.vo";
2
+ export * from "./basename.vo";
2
3
  export * from "./build-version.vo";
3
4
  export * from "./clock.vo";
4
5
  export * from "./date-calculator.service";
@@ -7,10 +8,17 @@ export * from "./date-range.vo";
7
8
  export * from "./dates-of-the-week.vo";
8
9
  export * from "./day.vo";
9
10
  export * from "./day-iso-id.vo";
11
+ export * from "./directory-path-absolute.vo";
12
+ export * from "./directory-path-relative.vo";
10
13
  export * from "./dll.service";
11
14
  export * from "./email-mask.service";
12
15
  export * from "./etags.vo";
16
+ export * from "./extension.vo";
13
17
  export * from "./feature-flag.vo";
18
+ export * from "./file-path.vo";
19
+ export * from "./filename.vo";
20
+ export * from "./filename-from-string.vo";
21
+ export * from "./filename-suffix.vo";
14
22
  export * from "./filter.vo";
15
23
  export * from "./hour.vo";
16
24
  export * from "./iban.vo";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./api-key.vo";
2
+ export * from "./basename.vo";
2
3
  export * from "./build-version.vo";
3
4
  export * from "./clock.vo";
4
5
  export * from "./date-calculator.service";
@@ -7,10 +8,17 @@ export * from "./date-range.vo";
7
8
  export * from "./dates-of-the-week.vo";
8
9
  export * from "./day.vo";
9
10
  export * from "./day-iso-id.vo";
11
+ export * from "./directory-path-absolute.vo";
12
+ export * from "./directory-path-relative.vo";
10
13
  export * from "./dll.service";
11
14
  export * from "./email-mask.service";
12
15
  export * from "./etags.vo";
16
+ export * from "./extension.vo";
13
17
  export * from "./feature-flag.vo";
18
+ export * from "./file-path.vo";
19
+ export * from "./filename.vo";
20
+ export * from "./filename-from-string.vo";
21
+ export * from "./filename-suffix.vo";
14
22
  export * from "./filter.vo";
15
23
  export * from "./hour.vo";
16
24
  export * from "./iban.vo";
package/dist/mime.vo.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ExtensionType } from "./extension.vo";
1
2
  export type MimeRawType = string;
2
3
  type MimeTypeType = string;
3
4
  type MimeSubtypeType = string;
@@ -7,6 +8,7 @@ export declare class Mime {
7
8
  readonly subtype: MimeSubtypeType;
8
9
  constructor(value: MimeRawType);
9
10
  isSatisfiedBy(another: Mime): boolean;
11
+ toExtension(): ExtensionType;
10
12
  }
11
13
  export declare class InvalidMimeError extends Error {
12
14
  constructor();
package/dist/mime.vo.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ExtensionSchema } from "./extension.vo";
1
2
  export class Mime {
2
3
  raw;
3
4
  type;
@@ -22,6 +23,9 @@ export class Mime {
22
23
  return false;
23
24
  return this.subtype === another.subtype || this.subtype === "*";
24
25
  }
26
+ toExtension() {
27
+ return ExtensionSchema.parse(this.subtype);
28
+ }
25
29
  }
26
30
  export class InvalidMimeError extends Error {
27
31
  constructor() {
@@ -1 +1 @@
1
- {"root":["../src/api-key.vo.ts","../src/build-version.vo.ts","../src/clock.vo.ts","../src/date-calculator.service.ts","../src/date-formatter.service.ts","../src/date-range.vo.ts","../src/dates-of-the-week.vo.ts","../src/day-iso-id.vo.ts","../src/day.vo.ts","../src/dll.service.ts","../src/email-mask.service.ts","../src/etags.vo.ts","../src/feature-flag.vo.ts","../src/filter.vo.ts","../src/hour.vo.ts","../src/iban-mask.service.ts","../src/iban.vo.ts","../src/image.vo.ts","../src/index.ts","../src/language.vo.ts","../src/leap-year-checker.service.ts","../src/mean.service.ts","../src/mime-types.vo.ts","../src/mime.vo.ts","../src/min-max-scaler.service.ts","../src/minute.vo.ts","../src/money.vo.ts","../src/noop.service.ts","../src/notification-template.vo.ts","../src/outlier-detector.service.ts","../src/package-version.vo.ts","../src/pagination.service.ts","../src/percentage.service.ts","../src/population-standard-deviation.service.ts","../src/random.service.ts","../src/rate-limiter.service.ts","../src/relative-date.vo.ts","../src/reordering.service.ts","../src/revision.vo.ts","../src/rounding.service.ts","../src/simple-linear-regression.service.ts","../src/size.vo.ts","../src/stepper.service.ts","../src/stopwatch.service.ts","../src/streak-calculator.service.ts","../src/sum.service.ts","../src/thousands-separator.service.ts","../src/time-zone-offset-value.vo.ts","../src/time.service.ts","../src/timestamp.vo.ts","../src/timezone.vo.ts","../src/ts-utils.ts","../src/visually-unambiguous-characters-generator.service.ts","../src/week-iso-id.vo.ts","../src/week.vo.ts","../src/z-score.service.ts"],"version":"5.9.2"}
1
+ {"root":["../src/api-key.vo.ts","../src/basename.vo.ts","../src/build-version.vo.ts","../src/clock.vo.ts","../src/date-calculator.service.ts","../src/date-formatter.service.ts","../src/date-range.vo.ts","../src/dates-of-the-week.vo.ts","../src/day-iso-id.vo.ts","../src/day.vo.ts","../src/directory-path-absolute.vo.ts","../src/directory-path-relative.vo.ts","../src/dll.service.ts","../src/email-mask.service.ts","../src/etags.vo.ts","../src/extension.vo.ts","../src/feature-flag.vo.ts","../src/file-path.vo.ts","../src/filename-from-string.vo.ts","../src/filename-suffix.vo.ts","../src/filename.vo.ts","../src/filter.vo.ts","../src/hour.vo.ts","../src/iban-mask.service.ts","../src/iban.vo.ts","../src/image.vo.ts","../src/index.ts","../src/language.vo.ts","../src/leap-year-checker.service.ts","../src/mean.service.ts","../src/mime-types.vo.ts","../src/mime.vo.ts","../src/min-max-scaler.service.ts","../src/minute.vo.ts","../src/money.vo.ts","../src/noop.service.ts","../src/notification-template.vo.ts","../src/outlier-detector.service.ts","../src/package-version.vo.ts","../src/pagination.service.ts","../src/percentage.service.ts","../src/population-standard-deviation.service.ts","../src/random.service.ts","../src/rate-limiter.service.ts","../src/relative-date.vo.ts","../src/reordering.service.ts","../src/revision.vo.ts","../src/rounding.service.ts","../src/simple-linear-regression.service.ts","../src/size.vo.ts","../src/stepper.service.ts","../src/stopwatch.service.ts","../src/streak-calculator.service.ts","../src/sum.service.ts","../src/thousands-separator.service.ts","../src/time-zone-offset-value.vo.ts","../src/time.service.ts","../src/timestamp.vo.ts","../src/timezone.vo.ts","../src/ts-utils.ts","../src/visually-unambiguous-characters-generator.service.ts","../src/week-iso-id.vo.ts","../src/week.vo.ts","../src/z-score.service.ts"],"version":"5.9.2"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bgord/tools",
3
- "version": "0.12.12",
3
+ "version": "0.12.14",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "Bartosz Gordon",
@@ -30,11 +30,14 @@
30
30
  "lefthook": "1.12.3",
31
31
  "only-allow": "1.2.1",
32
32
  "shellcheck": "4.1.0",
33
- "typescript": "5.9.2"
33
+ "typescript": "5.9.2",
34
+ "zod": "4.1.1"
34
35
  },
35
36
  "dependencies": {
36
- "date-fns": "4.1.0",
37
- "zod": "3.25.76"
37
+ "date-fns": "4.1.0"
38
+ },
39
+ "peerDependencies": {
40
+ "zod": "4.1.1"
38
41
  },
39
42
  "sideEffects": false
40
43
  }
package/readme.md CHANGED
@@ -25,6 +25,7 @@ Run the tests
25
25
  ```
26
26
  src/
27
27
  ├── api-key.vo.ts
28
+ ├── basename.vo.ts
28
29
  ├── build-version.vo.ts
29
30
  ├── clock.vo.ts
30
31
  ├── date-calculator.service.ts
@@ -33,10 +34,17 @@ src/
33
34
  ├── dates-of-the-week.vo.ts
34
35
  ├── day-iso-id.vo.ts
35
36
  ├── day.vo.ts
37
+ ├── directory-path-absolute.vo.ts
38
+ ├── directory-path-relative.vo.ts
36
39
  ├── dll.service.ts
37
40
  ├── email-mask.service.ts
38
41
  ├── etags.vo.ts
42
+ ├── extension.vo.ts
39
43
  ├── feature-flag.vo.ts
44
+ ├── file-path.vo.ts
45
+ ├── filename-from-string.vo.ts
46
+ ├── filename-suffix.vo.ts
47
+ ├── filename.vo.ts
40
48
  ├── filter.vo.ts
41
49
  ├── hour.vo.ts
42
50
  ├── iban-mask.service.ts
@@ -0,0 +1,19 @@
1
+ import { z } from "zod/v4";
2
+
3
+ export const BasenameSchema = z
4
+ .string()
5
+ .trim()
6
+ .min(1, "basename_empty")
7
+ .max(128, "basename_too_long")
8
+ .refine((s) => !/[/\\]/.test(s), "basename_slashes_forbidden")
9
+ // biome-ignore lint: lint/suspicious/noControlCharactersInRegex
10
+ .refine((s) => !/[\u0000-\u001F\u007F]/.test(s), "basename_control_chars_forbidden")
11
+ // check dot-segments FIRST so "." / ".." get the intended error
12
+ .refine((s) => s !== "." && s !== "..", "basename_dot_segments_forbidden")
13
+ // then disallow any other dotfile (".env", ".gitignore", etc.)
14
+ .refine((s) => !s.startsWith("."), "basename_dotfiles_forbidden")
15
+ .refine((s) => !s.endsWith("."), "basename_trailing_dot_forbidden")
16
+ .regex(/^[A-Za-z0-9._-]+$/, "basename_bad_chars")
17
+ .brand("basename");
18
+
19
+ export type BasenameType = z.infer<typeof BasenameSchema>;
@@ -0,0 +1,22 @@
1
+ import { z } from "zod/v4";
2
+
3
+ export const DirectoryPathAbsoluteSchema = z
4
+ .string()
5
+ .trim()
6
+ .refine((value) => value.startsWith("/"), "abs_dir_must_start_with_slash")
7
+ .refine((value) => !value.includes("\\"), "abs_dir_backslash_forbidden")
8
+ // biome-ignore lint: lint/suspicious/noControlCharactersInRegex
9
+ .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), "abs_dir_control_chars_forbidden")
10
+ .transform((value) => value.replace(/\/{2,}/g, "/"))
11
+ .transform((value) => (value !== "/" && value.endsWith("/") ? value.slice(0, -1) : value))
12
+ .refine((value) => {
13
+ if (value === "/") return true;
14
+ const segments = value.slice(1).split("/");
15
+ return segments.every(
16
+ (segment) =>
17
+ segment.length > 0 && /^[A-Za-z0-9._-]+$/.test(segment) && segment !== "." && segment !== "..",
18
+ );
19
+ }, "abs_dir_bad_segments")
20
+ .brand("absolute_directory_path");
21
+
22
+ export type DirectoryPathAbsoluteType = z.infer<typeof DirectoryPathAbsoluteSchema>;
@@ -0,0 +1,22 @@
1
+ import { z } from "zod/v4";
2
+
3
+ export const DirectoryPathRelativeSchema = z
4
+ .string()
5
+ .trim()
6
+ .refine((value) => !value.startsWith("/"), "rel_dir_must_not_start_with_slash")
7
+ .refine((value) => !value.includes("\\"), "rel_dir_backslash_forbidden")
8
+ // biome-ignore lint: lint/suspicious/noControlCharactersInRegex
9
+ .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), "rel_dir_control_chars_forbidden")
10
+ .transform((value) => value.replace(/\/{2,}/g, "/"))
11
+ .transform((value) => value.replace(/^\/+|\/+$/g, ""))
12
+ .refine((value) => value.length > 0, "rel_dir_empty")
13
+ .refine(
14
+ (value) =>
15
+ value
16
+ .split("/")
17
+ .every((segment) => /^[A-Za-z0-9._-]+$/.test(segment) && segment !== "." && segment !== ".."),
18
+ "rel_dir_bad_segments",
19
+ )
20
+ .brand("relative_directory_path");
21
+
22
+ export type DirectoryPathRelativeType = z.infer<typeof DirectoryPathRelativeSchema>;
@@ -0,0 +1,17 @@
1
+ import { z } from "zod/v4";
2
+
3
+ export const ExtensionSchema = z
4
+ .string()
5
+ .trim()
6
+ .transform((value) => (value.startsWith(".") ? value.slice(1) : value))
7
+ .transform((value) => value.toLowerCase())
8
+ .pipe(
9
+ z
10
+ .string()
11
+ .min(1, "extension_empty")
12
+ .max(16, "extension_too_long")
13
+ .regex(/^[a-z0-9]+$/, "extension_bad_chars"),
14
+ )
15
+ .brand("extension");
16
+
17
+ export type ExtensionType = z.infer<typeof ExtensionSchema>;
@@ -0,0 +1,84 @@
1
+ import { DirectoryPathAbsoluteSchema, type DirectoryPathAbsoluteType } from "./directory-path-absolute.vo";
2
+ import { DirectoryPathRelativeSchema, type DirectoryPathRelativeType } from "./directory-path-relative.vo";
3
+ import type { Filename } from "./filename.vo";
4
+
5
+ export class RelativeFilePath {
6
+ private constructor(
7
+ private readonly directory: DirectoryPathRelativeType,
8
+ private readonly filename: Filename,
9
+ ) {}
10
+
11
+ static fromParts(directoryCandidate: string, filename: Filename) {
12
+ const directory = DirectoryPathRelativeSchema.parse(directoryCandidate);
13
+ return new RelativeFilePath(directory, filename);
14
+ }
15
+
16
+ static fromPartsSafe(directory: DirectoryPathRelativeType, filename: Filename) {
17
+ return new RelativeFilePath(directory, filename);
18
+ }
19
+
20
+ get() {
21
+ return `${this.directory}/${this.filename.get()}`;
22
+ }
23
+
24
+ getDirectory(): DirectoryPathRelativeType {
25
+ return this.directory;
26
+ }
27
+
28
+ getFilename(): Filename {
29
+ return this.filename;
30
+ }
31
+
32
+ withDirectoryRelative(newDirectory: DirectoryPathRelativeType): RelativeFilePath {
33
+ return new RelativeFilePath(newDirectory, this.filename);
34
+ }
35
+
36
+ withFilename(newFilename: Filename): RelativeFilePath {
37
+ return new RelativeFilePath(this.directory, newFilename);
38
+ }
39
+
40
+ toAbsolute(newDirectory: DirectoryPathAbsoluteType): AbsoluteFilePath {
41
+ return AbsoluteFilePath.fromPartsSafe(newDirectory, this.filename);
42
+ }
43
+ }
44
+
45
+ export class AbsoluteFilePath {
46
+ private constructor(
47
+ private readonly directory: DirectoryPathAbsoluteType,
48
+ private readonly filename: Filename,
49
+ ) {}
50
+
51
+ static fromParts(directoryCandidate: string, filename: Filename) {
52
+ const directory = DirectoryPathAbsoluteSchema.parse(directoryCandidate);
53
+ return new AbsoluteFilePath(directory, filename);
54
+ }
55
+
56
+ static fromPartsSafe(directory: DirectoryPathAbsoluteType, filename: Filename) {
57
+ return new AbsoluteFilePath(directory, filename);
58
+ }
59
+
60
+ get() {
61
+ if (this.directory === ("/" as DirectoryPathAbsoluteType)) return `/${this.filename.get()}`;
62
+ return `${this.directory}/${this.filename.get()}`;
63
+ }
64
+
65
+ getDirectory(): DirectoryPathAbsoluteType {
66
+ return this.directory;
67
+ }
68
+
69
+ getFilename(): Filename {
70
+ return this.filename;
71
+ }
72
+
73
+ withDirectoryAbsolute(newDirectory: DirectoryPathAbsoluteType): AbsoluteFilePath {
74
+ return new AbsoluteFilePath(newDirectory, this.filename);
75
+ }
76
+
77
+ withFilename(newFilename: Filename): AbsoluteFilePath {
78
+ return new AbsoluteFilePath(this.directory, newFilename);
79
+ }
80
+
81
+ toRelative(newDirectory: DirectoryPathRelativeType): RelativeFilePath {
82
+ return RelativeFilePath.fromPartsSafe(newDirectory, this.filename);
83
+ }
84
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from "zod/v4";
2
+ import { BasenameSchema } from "./basename.vo";
3
+ import { ExtensionSchema } from "./extension.vo";
4
+
5
+ export const FilenameFromStringSchema = z
6
+ .string()
7
+ .trim()
8
+ .refine((string) => {
9
+ const index = string.lastIndexOf(".");
10
+ return index > 0 && index < string.length - 1;
11
+ }, "filename_invalid")
12
+ // split and validate parts using existing schemas
13
+ .transform((string) => {
14
+ const index = string.lastIndexOf(".");
15
+ const base = BasenameSchema.parse(string.slice(0, index));
16
+ const extension = ExtensionSchema.parse(string.slice(index + 1));
17
+ return { basename: base, extension: extension };
18
+ });
19
+
20
+ export type FilenameFromString = z.infer<typeof FilenameFromStringSchema>;
@@ -0,0 +1,10 @@
1
+ import { z } from "zod/v4";
2
+
3
+ export const FilenameSuffixSchema = z
4
+ .string()
5
+ .trim()
6
+ .transform((value) => value.replace(/[^A-Za-z0-9_-]/g, ""))
7
+ .pipe(z.string().max(32, "suffix_too_long"))
8
+ .brand("basename_suffix");
9
+
10
+ export type FilenameSuffixSchemaType = z.infer<typeof FilenameSuffixSchema>;
@@ -0,0 +1,58 @@
1
+ import { BasenameSchema, type BasenameType } from "./basename.vo";
2
+ import { ExtensionSchema, type ExtensionType } from "./extension.vo";
3
+ import { FilenameFromStringSchema } from "./filename-from-string.vo";
4
+ import { FilenameSuffixSchema, type FilenameSuffixSchemaType } from "./filename-suffix.vo";
5
+
6
+ export class Filename {
7
+ private constructor(
8
+ private readonly basename: BasenameType,
9
+ private readonly extension: ExtensionType,
10
+ ) {}
11
+
12
+ static fromParts(basename: string, extension: string) {
13
+ return new Filename(BasenameSchema.parse(basename), ExtensionSchema.parse(extension));
14
+ }
15
+
16
+ static fromPartsSafe(basename: BasenameType, extension: ExtensionType) {
17
+ return new Filename(basename, extension);
18
+ }
19
+
20
+ static fromString(candidate: string) {
21
+ const { basename, extension } = FilenameFromStringSchema.parse(candidate);
22
+
23
+ return new Filename(basename, extension);
24
+ }
25
+
26
+ get() {
27
+ return `${this.basename}.${this.extension}`;
28
+ }
29
+
30
+ getBasename(): BasenameType {
31
+ return this.basename;
32
+ }
33
+
34
+ getExtension(): ExtensionType {
35
+ return this.extension;
36
+ }
37
+
38
+ withExtension(extension: ExtensionType): Filename {
39
+ return new Filename(this.basename, extension);
40
+ }
41
+
42
+ withBasename(basename: BasenameType): Filename {
43
+ return new Filename(basename, this.extension);
44
+ }
45
+
46
+ withSuffix(candidate: string): Filename {
47
+ const suffix = FilenameSuffixSchema.parse(candidate);
48
+ const basename = BasenameSchema.parse(`${this.basename}${suffix}`);
49
+
50
+ return new Filename(basename, this.extension);
51
+ }
52
+
53
+ withSuffixSafe(suffix: FilenameSuffixSchemaType): Filename {
54
+ const basename = BasenameSchema.parse(`${this.basename}${suffix}`);
55
+
56
+ return new Filename(basename, this.extension);
57
+ }
58
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./api-key.vo";
2
+ export * from "./basename.vo";
2
3
  export * from "./build-version.vo";
3
4
  export * from "./clock.vo";
4
5
  export * from "./date-calculator.service";
@@ -7,10 +8,17 @@ export * from "./date-range.vo";
7
8
  export * from "./dates-of-the-week.vo";
8
9
  export * from "./day.vo";
9
10
  export * from "./day-iso-id.vo";
11
+ export * from "./directory-path-absolute.vo";
12
+ export * from "./directory-path-relative.vo";
10
13
  export * from "./dll.service";
11
14
  export * from "./email-mask.service";
12
15
  export * from "./etags.vo";
16
+ export * from "./extension.vo";
13
17
  export * from "./feature-flag.vo";
18
+ export * from "./file-path.vo";
19
+ export * from "./filename.vo";
20
+ export * from "./filename-from-string.vo";
21
+ export * from "./filename-suffix.vo";
14
22
  export * from "./filter.vo";
15
23
  export * from "./hour.vo";
16
24
  export * from "./iban.vo";
package/src/mime.vo.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { ExtensionSchema, type ExtensionType } from "./extension.vo";
2
+
1
3
  export type MimeRawType = string;
2
4
 
3
5
  type MimeTypeType = string;
@@ -33,6 +35,10 @@ export class Mime {
33
35
 
34
36
  return this.subtype === another.subtype || this.subtype === "*";
35
37
  }
38
+
39
+ toExtension(): ExtensionType {
40
+ return ExtensionSchema.parse(this.subtype);
41
+ }
36
42
  }
37
43
 
38
44
  export class InvalidMimeError extends Error {