UNPKG

@thingts/path

Version:

Type-safe, ergonomic package for working with paths in any javascript environment

579 lines (571 loc) 18.8 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { AbsolutePath: () => AbsolutePath, Filename: () => Filename, RelativePath: () => RelativePath }); module.exports = __toCommonJS(index_exports); // src/path-tools.ts var SEPARATOR = "/"; var sep = SEPARATOR; function extname(filename) { const dot = filename.lastIndexOf("."); const sep2 = filename.lastIndexOf(SEPARATOR); return dot > 0 && dot > sep2 ? filename.slice(dot) : ""; } function basename(filename, ext) { const base = filename.slice(filename.lastIndexOf(SEPARATOR) + 1); if (ext && base.endsWith(ext)) { return base.slice(0, base.length - ext.length); } return base; } function dirname(p) { if (p.length === 0) { return "."; } while (p.length > 1 && p.endsWith(SEPARATOR)) { p = p.slice(0, -1); } const idx = p.lastIndexOf(SEPARATOR); if (idx === -1) return "."; if (idx === 0) return SEPARATOR; return p.slice(0, idx); } function joinOrResolve(segments, mode) { const filtered = segments.filter((s) => !!s); const resolvedParts = []; let firstSegment = segments[0]; for (let i = filtered.length - 1; i >= 0; i--) { const segment = filtered[i]; resolvedParts.unshift(...segment.split(SEPARATOR)); if (mode === "resolve" && segment.startsWith(SEPARATOR)) { firstSegment = segment; break; } } return (firstSegment?.startsWith(SEPARATOR) ? SEPARATOR : "") + normalizeSegments(resolvedParts); } function normalizeSegments(segments) { const normalized = []; for (const segment of segments) { if (!segment || segment === ".") continue; if (segment === "..") { if (normalized.length > 0 && normalized.at(-1) !== "..") normalized.pop(); } else { normalized.push(segment); } } return normalized.join(SEPARATOR); } function resolve(...segments) { return joinOrResolve(segments, "resolve"); } function join(...segments) { return joinOrResolve(segments, "join"); } function normalize(p) { const leadingDot = "." + SEPARATOR; let normalized = normalizeSegments(p.split(SEPARATOR)); if (isAbsolute(p)) { normalized = SEPARATOR + normalized; } if (normalized.startsWith(leadingDot)) { normalized = normalized.slice(leadingDot.length); } if (normalized === SEPARATOR) { return normalized; } if (normalized.endsWith(SEPARATOR)) { normalized = normalized.slice(0, -SEPARATOR.length); } if (normalized === "") { normalized = "."; } return normalized; } function relative(from, to) { const fromParts = resolve(from).split(SEPARATOR).filter(Boolean); const toParts = resolve(to).split(SEPARATOR).filter(Boolean); let i = 0; while (i < fromParts.length && i < toParts.length && fromParts[i] === toParts[i]) { i++; } const upLevels = fromParts.length - i; const downParts = toParts.slice(i); return [ ...Array(upLevels).fill(".."), ...downParts ].join("/") || "."; } function isAbsolute(p) { return p.startsWith(SEPARATOR); } // src/filename-base.ts var FilenameBase = class { ///////////////////////////////////////////////////////////////////////////// // // --- Getters for filename properties --- // ///////////////////////////////////////////////////////////////////////////// /** * Returns the extension of the filename including the leading dot, as a * string. If the filename has no extension, returns an empty string. * * Note that if the filename starts with a dot (e.g. `.gitignore`), * that dot is considered part of the stem. So `.gitignore` has * extension `''` and `.gitignore.bak` has extension `.bak` */ get extension() { return extname(this.filename_); } /** * Returns the stem of the filename, i.e. the part before the extension. * If the filename has no extension, returns the entire filename * * Note that if the filename starts with a dot (e.g. `.gitignore`), * that dot is considered part of the stem. So `.gitignore` and * `.gitignore.bak` both have stem `.gitignore` */ get stem() { return basename(this.filename_, this.extension); } ///////////////////////////////////////////////////////////////////////////// // // --- Filename manipulation methods --- // ///////////////////////////////////////////////////////////////////////////// /** * Replace the filename stem, keeping the extension the same * * @returns A new {@link Filename} instance * * @example * ```ts * new Filename('index.ts').replaceStem('main') // 'main.ts' (Filename) * ``` */ replaceStem(newStem) { return this.withFilename(newStem + this.extension); } /** * Replace the filename extensions, keeping the stem the same * * @returns A new {@link Filename} instance * * @example * ```ts * new Filename('index.ts').replaceExtension('.js') // 'index.js' (Filename) * ``` */ replaceExtension(newExt) { const ext = newExt.startsWith(".") ? newExt : "." + newExt; return this.withFilename(this.stem + ext); } }; // src/filename.ts var Filename = class _Filename extends FilenameBase { filename_; /** * Create a {@link Filename} instance from a string or another {@link Filename} * * Throws an error if the provided name contains path separators * * @example * ```ts * new Filename('index.ts') // OK * new Filename('demo/index.ts') // Throws Error * ``` */ constructor(filename) { super(); filename = String(filename); if (!_Filename.isFilenameString(filename)) { throw new Error(`Filename must not contain path components: "${filename}"`); } this.filename_ = filename; } ///////////////////////////////////////////////////////////////////////////// // // --- Filename manipulaion methods --- // ///////////////////////////////////////////////////////////////////////////// /** * Creates a new {@link Filename} by calling a callback function with the * old filename as a string, and using the returned string as the new * filename * * @returns A new {@link Filename} instance */ transform(fn) { return this.withFilename(fn(this.filename_)); } ///////////////////////////////////////////////////////////////////////////// // // --- Static helpers --- // ///////////////////////////////////////////////////////////////////////////// /** * Returns true if the provided string is a valid filename (i.e. does not * contain any path separators) */ static isFilenameString(filename) { return filename === basename(filename); } ///////////////////////////////////////////////////////////////////////////// // // --- FilenameBase abstract method implementations --- // ///////////////////////////////////////////////////////////////////////////// toString() { return this.filename_; } equals(other) { return this.filename_ === String(other); } withFilename(filename) { return new _Filename(filename); } }; // src/path-base.ts var PathBase = class extends FilenameBase { /** * Protected factory to construct a new instance of the current class, with * the given path. * * Used by all mutation-like methods to return a new instance of the same * class, allowing derived classes that inherit those methods to return new * instances of themselves without needing to override them. * * The default implementation assumes the derived class's constructor takes * a single string argument (the path). Derived classes with different * constructor siguatures should override {@link newSelf}. */ newSelf(path) { const ctor = this.constructor; return new ctor(String(path)); } ///////////////////////////////////////////////////////////////////////////// // // --- Getters for path properties --- // ///////////////////////////////////////////////////////////////////////////// /** * The filename component (last path segment) as a {@link Filename}. * * @example * ```ts * new AbsolutePath('/a/b/c.txt').filename // 'c.txt' (Filename) * new RelativePath('a/b/c.txt').filename // 'c.txt' (Filename) * ``` */ get filename() { return new Filename(this.filename_); } /** * The parent directory of this path. * * @returns A new path instance pointing to the parent. * @example * ```ts * new AbsolutePath('/a/b/c.txt').parent // '/a/b' (AbsolutePath) * new RelativePath('a/b/c.txt').parent // 'a/b' (RelativePath) * ``` */ get parent() { return this.newSelf(dirname(this.path_)); } ///////////////////////////////////////////////////////////////////////////// // // --- Path manipulation methods --- // ///////////////////////////////////////////////////////////////////////////// /** * Join additional path segments to this path. * * Accepts strings or path objects; `null` and `undefined` are ignored. * The resulting path is normalized. * * @returns A new path instance with the segments appended * * @example * ```ts * const a1 = new AbsolutePath('/project/demo') * const a2 = a1.join('demo1/src', 'index.js') // '/project/demo/demo1/src/index.js' * a2 instanceof AbsolutePath // true * * const r1 = new RelativePath('demo') * const r2 = r1.join('demo1/src', 'index.js') // 'demo/demo1/src/index.js' * r2 instanceof RelativePath // true * ``` */ join(...segments) { return this.newSelf(join(this.path_, ...segments.filter((s) => s !== null && s !== void 0).map((s) => String(s)))); } /** * Replace the filename (last segment). * * @returns A new path with the filename replaced. */ replaceFilename(newFilename) { return this.withFilename(String(newFilename)); } /** * Replace the filename stem, keeping the extension the same * * @param newStem - New stem to use (extension is preserved). * @returns A new path with the stem replaced. * @example * ```ts * new AbsolutePath('/a/b/c.txt').replaceStem('d') // '/a/b/d.txt' (AbsolutePath) * new RelativePath('a/b/c.txt').replaceStem('d') // 'a/b/d.txt' (RelativePath) * ``` */ replaceStem(newStem) { return this.withFilename(this.filename.replaceStem(newStem)); } /** * Replace the filename extension, keeping the stem the same. The passed * can include or omit the leading dot; if omitted, it will be added. * * @param newExt - New extension, e.g. `json` or `.json` * @returns A new path with the extension replaced. * @example * ```ts * new AbsolutePath('/a/b/c.txt').replaceExtension('json') // '/a/b/c.json' (AbsolutePath) * new RelativePath('a/b/c.txt').replaceExtension('.json') // '/a/b/c.json' (RelativePath) * ``` */ replaceExtension(newExt) { return this.withFilename(this.filename.replaceExtension(newExt)); } /** * Transform the filename via a callback. * * @param fn - Receives the current {@link Filename}, returns a new filename * (string or {@link Filename}). * @returns A new path with the transformed filename. * @example * ```ts * p.transformFilename(f => f.replaceStem(f.stem + '.bak')) * ``` */ transformFilename(fn) { return this.withFilename(fn(this.filename)); } /** * Replace the parent directory while keeping the current filename. * * @param newParent - Parent directory as string or another `PathBase`. * @returns A new path rooted at `newParent` with the same filename. * @example * ```ts * new AbsolutePath('/old/file.txt').replaceParent('/new/dir') // '/new/dir/file.txt' (AbsolutePath) * new RelativePath('old/file.txt').replaceParent('new/dir') // 'new/dir/file.txt' (RelativePath) * ``` */ replaceParent(newParent) { return this.newSelf(join(String(newParent), this.filename_)); } ///////////////////////////////////////////////////////////////////////////// // // --- FilenameBase abstract method implemenations --- // ///////////////////////////////////////////////////////////////////////////// /** Returns the path as string. */ toString() { return this.path_; } /** Returns true if this path equals the other path or string */ equals(other) { return this.path_ === this.newSelf(String(other)).path_; } get filename_() { return basename(this.path_); } withFilename(filename) { return this.parent.join(filename); } }; // src/relative-path.ts var RelativePath = class _RelativePath extends PathBase { path_; /** * Create a new {@link RelativePath} from a string or another {@link RelativePath}. * * The path is normalized and guaranteed to be relative. Any trailing * separator is removed. * * Throws an error if the provided path is absolute * * @example * ```ts * new AbsolutePath('project/demos') // OK * new AbsolutePath('/project/demos') // Throws Error * new AbsolutePath('project//src/../demos/') // normalized => project/demos * ``` */ constructor(relpath) { super(); relpath = String(relpath); if (!_RelativePath.isRelativePathString(relpath)) { throw new Error(`Path must be relative, not absolute: "${relpath}"`); } this.path_ = normalize(relpath); } ///////////////////////////////////////////////////////////////////////////// // // --- Static helpers --- // ///////////////////////////////////////////////////////////////////////////// /** * Checks whether a string is a relative path. (I.e., if it would be * acceptable to the {@link RelativePath} constructor.) * * @param filepath - The string to check. * @returns True if the string is an absolute path, otherwise false. */ static isRelativePathString(filepath) { return !isAbsolute(filepath); } }; // src/absolute-path.ts var AbsolutePath = class _AbsolutePath extends PathBase { path_; /** * Create a new {@link AbsolutePath} from a string or another {@link AbsolutePath}. * * The path is normalized and guaranteed to be absolute. Any trailing * separator is removed. * * Throws an error if the provided path is not absolute. * * @example * ```ts * new AbsolutePath('/project/demos') // OK * new AbsolutePath('project/demos') // Throws Error * new AbsolutePath('/project//src/../demos/') // normalized => /project/demos * ``` */ constructor(path) { super(); this.path_ = _AbsolutePath.#canonicalize(String(path)); } static #canonicalize(p) { return normalize(resolve(p)); } ///////////////////////////////////////////////////////////////////////////// // // --- Path manipulation methods --- // ///////////////////////////////////////////////////////////////////////////// /** * Resolve additional path segments against this absolute path. * * Accepts strings, {@link Filename}, {@link RelativePath}, or {@link AbsolutePath} objects. * Null and undefined segments are ignored. * * Similar to join, except that if any segment is an {@link AbsolutePath} or string * starting with a path separator, the current path is discarded and * resolution starts from that segment. * * @returns A new {@link AbsolutePath} with the resolved path. * * @example * ```ts * const p1 = new AbsolutePath('/project/demos') * const p2 = p1.resolve('demo1/src', 'index.ts') // '/project/demos/demo1/src/index.ts' * const p3 = p1.resolve('/etc/config') // '/etc/config' (resets to absolute path) * ``` */ resolve(...segments) { return this.newSelf(resolve(this.path_, ...segments.filter((s) => s != null).map((s) => String(s)))); } ///////////////////////////////////////////////////////////////////////////// // // --- Path query methods --- // ///////////////////////////////////////////////////////////////////////////// /** * Compute the relative path from the given base path to this path. * * @param base - The base absolute path. * @returns A {@link RelativePath} that goes from `base` to `this`. * * @example * ```ts * const p1 = new AbsolutePath('/project/demo') * const p2 = new AbsolutePath('/project/demo/src/index.ts') * const rel = p2.relativeTo(p1) // 'src/index.ts' (RelativePath) * p1.join(rel).equals(p2) // true * ``` */ relativeTo(base) { return new RelativePath(relative(base.path_, this.path_)); } /** * Test whether this path is a descendant of the given ancestor path. * * @param ancestor - An `AbsolutePath` or string to check against. * @param opts.includeSelf - If true, return true when the paths are identical. * @returns True if this path descends from the ancestor, otherwise false. * * @example * ```ts * const p1 = new AbsolutePath('/project/demo') * const p2 = new AbsolutePath('/project/demo/src/index.ts') * console.log(p2.descendsFrom(p1)) // true * console.log(p1.descendsFrom(p1)) // false * console.log(p1.descendsFrom(p1, { includeSelf: true })) // true * ``` */ descendsFrom(ancestor, opts) { const { includeSelf = false } = opts ?? {}; const ancestorPath = resolve(String(ancestor)); const current = this.path_; if (includeSelf && current === ancestorPath) { return true; } return current.startsWith(ancestorPath + sep); } ///////////////////////////////////////////////////////////////////////////// // // --- Static helpers --- // ///////////////////////////////////////////////////////////////////////////// /** * Checks whether a string is an absolute path. (I.e., if it would be * acceptable to the {@link AbsolutePath} constructor.) * * @param filepath - The string to check. * @returns True if the string is an absolute path, otherwise false. */ static isAbsolutePathString(filepath) { return isAbsolute(filepath); } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { AbsolutePath, Filename, RelativePath });