import process from 'process'; import { pathSatisfies, propOr, pipe, test, last } from 'ramda'; import { isUndefined, replaceAll, isNotUndefined, trimCharsEnd } from 'ramda-adjunct'; /** * SPDX-FileCopyrightText: Copyright (c) 2015 James Messinger * * SPDX-License-Identifier: MIT */ const isWindows = () => pathSatisfies(test(/^win/), ['platform'], process); /** * Returns the protocol of the given URL, or `undefined` if it has no protocol. */ export const getProtocol = url => { try { const parsedUrl = new URL(url); return trimCharsEnd(':', parsedUrl.protocol); } catch { return undefined; } }; /** * Returns true if given URL has protocol. */ export const hasProtocol = pipe(getProtocol, isNotUndefined); /** * Returns the lower-cased file extension of the given URL, * or an empty string if it has no extension. */ export const getExtension = url => { const lastDotPosition = url.lastIndexOf('.'); if (lastDotPosition >= 0) { return url.substring(lastDotPosition).toLowerCase(); } return ''; }; /** * Determines whether the given path is a filesystem path. * This includes "file://" URLs. */ export const isFileSystemPath = uri => { // @ts-ignore if (process.browser) { /** * We're running in a browser, so assume that all paths are URLs. * This way, even relative paths will be treated as URLs rather than as filesystem paths. */ return false; } const protocol = getProtocol(uri); return isUndefined(protocol) || protocol === 'file' || /^[a-zA-Z]$/.test(protocol); }; /** * Determines whether the given URI is an HTTP(S) URL. */ export const isHttpUrl = url => { const protocol = getProtocol(url); return protocol === 'http' || protocol === 'https'; }; /** * Determines whether the given URI * @param uri */ export const isURI = uri => { try { return new URL(uri) && true; } catch { return false; } }; /** * Converts a URL to a local filesystem path. */ export const toFileSystemPath = (uri, options) => { // RegExp patterns to URL-decode special characters for local filesystem paths const urlDecodePatterns = [/%23/g, '#', /%24/g, '$', /%26/g, '&', /%2C/g, ',', /%40/g, '@']; const keepFileProtocol = propOr(false, 'keepFileProtocol', options); const isWindowsPredicate = propOr(isWindows, 'isWindows', options); // Step 1: `decodeURI` will decode characters such as Cyrillic characters, spaces, etc. let path = decodeURI(uri); // Step 2: Manually decode characters that are not decoded by `decodeURI`. // This includes characters such as "#" and "?", which have special meaning in URLs, // but are just normal characters in a filesystem path. for (let i = 0; i < urlDecodePatterns.length; i += 2) { // @ts-ignore path = path.replace(urlDecodePatterns[i], urlDecodePatterns[i + 1]); } // Step 3: If it's a "file://" URL, then format it consistently // or convert it to a local filesystem path let isFileUrl = path.substring(0, 7).toLowerCase() === 'file://'; if (isFileUrl) { // Strip-off the protocol, and the initial "/", if there is one path = path[7] === '/' ? path.substring(8) : path.substring(7); // insert a colon (":") after the drive letter on Windows if (isWindowsPredicate() && path[1] === '/') { path = `${path[0]}:${path.substring(1)}`; } if (keepFileProtocol) { // Return the consistently-formatted "file://" URL path = `file:///${path}`; } else { // Convert the "file://" URL to a local filesystem path. // On Windows, it will start with something like "C:/". // On Posix, it will start with "/" isFileUrl = false; path = isWindowsPredicate() ? path : `/${path}`; } } // Step 4: Normalize Windows paths (unless it's a "file://" URL) if (isWindowsPredicate() && !isFileUrl) { // Replace forward slashes with backslashes path = replaceAll('/', '\\', path); // Capitalize the drive letter if (path.substring(1, 3) === ':\\') { path = path[0].toUpperCase() + path.substring(1); } } return path; }; /** * Converts a filesystem path to a properly-encoded URL. * * This is intended to handle situations where resolver is called * with a filesystem path that contains characters which are not allowed in URLs. * * @example * The following filesystem paths would be converted to the following URLs: * * <"!@#$%^&*+=?'>.json ==> %3C%22!@%23$%25%5E&*+=%3F\'%3E.json * C:\\My Documents\\File (1).json ==> C:/My%20Documents/File%20(1).json * file://Project #42/file.json ==> file://Project%20%2342/file.json */ export const fromFileSystemPath = uri => { const urlEncodePatterns = [/\?/g, '%3F', /#/g, '%23']; let path = uri; // Step 1: On Windows, replace backslashes with forward slashes, // rather than encoding them as "%5C" if (isWindows()) { path = path.replace(/\\/g, '/'); } // Step 2: `encodeURI` will take care of MOST characters path = encodeURI(path); // Step 3: Manually encode characters that are not encoded by `encodeURI`. // This includes characters such as "#" and "?", which have special meaning in URLs, // but are just normal characters in a filesystem path. for (let i = 0; i < urlEncodePatterns.length; i += 2) { // @ts-ignore path = path.replace(urlEncodePatterns[i], urlEncodePatterns[i + 1]); } return path; }; /** * Returns the hash (URL fragment), of the given path. * If there is no hash, then the root hash ("#") is returned. */ export const getHash = uri => { const hashIndex = uri.indexOf('#'); if (hashIndex !== -1) { return uri.substring(hashIndex); } return '#'; }; /** * Removes the hash (URL fragment), if any, from the given path. */ export const stripHash = uri => { const hashIndex = uri.indexOf('#'); let hashStrippedUri = uri; if (hashIndex >= 0) { hashStrippedUri = uri.substring(0, hashIndex); } return hashStrippedUri; }; /** * Returns the current working directory (in Node) or the current page URL (in browsers). */ export const cwd = () => { // @ts-ignore if (process.browser) { return stripHash(globalThis.location.href); } const path = process.cwd(); const lastChar = last(path); if (['/', '\\'].includes(lastChar)) { return path; } return path + (isWindows() ? '\\' : '/'); }; /** * Resolves a target URI relative to a base URI in a manner similar to that of a Web browser resolving an anchor tag HREF. */ export const resolve = (from, to) => { const resolvedUrl = new URL(to, new URL(from, 'resolve://')); if (resolvedUrl.protocol === 'resolve:') { // `from` is a relative URL. const { pathname, search, hash } = resolvedUrl; return pathname + search + hash; } return resolvedUrl.toString(); }; /** * Sanitizes/Encodes URI to it's url encoded form. * * The functional will compensate with the usecase when * already sanitized URI is passed to it, * by first unsatizing it and then performing sanitization again. */ export const sanitize = uri => { if (isFileSystemPath(uri)) { return fromFileSystemPath(toFileSystemPath(uri)); } try { return new URL(uri).toString(); } catch { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI#encoding_for_ipv6 return encodeURI(decodeURI(uri)).replace(/%5B/g, '[').replace(/%5D/g, ']'); } }; /** * Unsanitizes/Decodes URI to it's url encoded form. * This function already assumes that hash part of the URI * has been removed prior to transforming it to it's sanitized form. */ export const unsanitize = uri => { if (isFileSystemPath(uri)) { return toFileSystemPath(uri); } return decodeURI(uri); };