@thingts/path
Version:
Type-safe, ergonomic package for working with paths in any javascript environment
579 lines (571 loc) • 18.8 kB
JavaScript
"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
});