UNPKG

@rzl-zone/ziggy-route

Version:

Typed Laravel route generator for any frontend — works with React, Vue, Vanilla JS, and other JavaScript-based stacks.

598 lines (583 loc) 23.3 kB
/*! * ==================================================== * Rzl Ziggy-Route. * ---------------------------------------------------- * Version: 0.0.15. * Author: Rizalvin Dwiky. * Repository: https://github.com/rzl-zone/ziggy-route. * ==================================================== */ import { CONFIG, isNil, isEmptyString, isNull, isBoolean, getPreciseType, isError, isServer, isPlainObject, isArray, isString, isObject, safeStableStringify, isUndefined, realValue, isNumber, hasOwnProp } from './chunk-IWMGSFDN.esm.js'; import { stringify, parse } from 'qs'; var RouterConfigError = class extends Error { constructor(message, originalError, prefix) { const trimmedPrefix = prefix?.trim() || CONFIG.PACKAGE.PREFIX.NAME; const trimmedMessage = message.trim(); super( ` ${trimmedPrefix.startsWith("\u274C") ? trimmedPrefix : "\u274C " + trimmedPrefix} - ${trimmedMessage}` ); this.originalError = originalError; this.name = "RouteConfigError"; if (Error.captureStackTrace) { Error.captureStackTrace(this, RoutePropsError); } } }; var RoutePropsError = class _RoutePropsError extends Error { constructor(message, originalError, prefix) { const trimmedPrefix = prefix?.trim() || CONFIG.PACKAGE.PREFIX.NAME; const trimmedMessage = message.trim(); super( ` ${trimmedPrefix.startsWith("\u274C") ? trimmedPrefix : "\u274C " + trimmedPrefix} - ${trimmedMessage}` ); this.originalError = originalError; this.name = "RoutePropsError"; if (Error.captureStackTrace) { Error.captureStackTrace(this, _RoutePropsError); } } }; var RouteFactory = class { name; definition; config; wheres; bindings = {}; constructor(name, definition, config) { this.name = name; this.definition = definition; this.bindings = definition?.bindings ?? {}; this.wheres = definition?.wheres ?? {}; this.config = config; } /** ------------------------------------------------------- * * ***Get a 'template' of the complete URL for this route.*** * ------------------------------------------------------- * * @example * https://{team}.rzlzone.dev/user/{user} * * @return {String} Route template. */ get template() { const template = `${this.origin}/${this.definition?.uri}`.replace( /\/+$/, "" ); return isEmptyString(template) ? "/" : template; } /** ------------------------------------------------------- * * ***Get a template of the origin for this route.*** * ------------------------------------------------------- * * @example * https://{team}.rzlzone.dev/ * * @return {String} Route origin template. */ get origin() { if (!this.config?.absolute) return ""; if (this.definition?.domain) { const match = this.config.url.match(/^\w+:\/\//); if (!match) { throw new RouterConfigError( `Invalid \`config.url\` missing protocol (e.g., \`"http://"\` or \`"https://"\`).` ); } const protocol = match[0]; return `${protocol}${this.definition.domain}${this.config.port ? `:${this.config.port}` : ""}`; } return this.config.url; } /** ----------------------------------------------- * * ***Get an array of objects representing the parameters that this route accepts.*** * ----------------------------------------------- * * @example * [{ name: 'team', required: true }, { name: 'user', required: false }] * * @return {Array<{name:string,required:boolean}>} Parameter segments. */ get parameterSegments() { return this.template.match(/{[^}?]+\??}/g)?.map((segment) => ({ name: segment.replace(/{|\??}/g, ""), required: !/\?}$/.test(segment) })) ?? []; } /** ----------------------------------------------- * * ***Get whether this route's template matches the given URL.*** * ----------------------------------------------- */ matchesUrl(url) { if (!this.definition?.methods.includes("GET")) return false; const pattern = this.template.replace(/[.*+$()[\]]/g, "\\$&").replace( /(\/?){([^}?]*)(\??)}/g, (_, slash, segment, optional) => { const regex = `(?<${segment}>${this.wheres[segment]?.replace(/(^\^)|(\$$)/g, "") || "[^/?]+"})`; return optional ? `(${slash}${regex})?` : `${slash}${regex}`; } ).replace(/^\w+:\/\//, ""); const [location, query] = url.replace(/^\w+:\/\//, "").split("?"); const matches = new RegExp(`^${pattern}/?$`).exec(location) ?? new RegExp(`^${pattern}/?$`).exec(decodeURI(location)); if (matches) { for (const k in matches.groups) { matches.groups[k] = isString(matches.groups[k]) ? decodeURIComponent(matches.groups[k]) : matches.groups[k]; } return { params: matches.groups, query: parse(query) }; } return false; } /** ----------------------------------------------- * * ***Hydrate and return a complete URL for this route with the given parameters.*** * ----------------------------------------------- * * @param {Record<string,any>} params * @return {String} */ compile(params = {}) { const url = this.template.replace( /{([^}?]+)(\??)}/g, (_match, segment, optional) => { const value = params[segment]; if (!optional && isNil(value)) { throw new RoutePropsError( `Invalid \`"${segment}"\` parameter is required for route \`"${this.name}"\`.` ); } const wheresSegment = this.wheres[segment]; if (wheresSegment) { const regex = new RegExp(`^${wheresSegment}$`); if (isString(value) && !regex.test(value)) { throw new RoutePropsError( `Invalid \`"${segment}"\` parameter \`"${value}"\` does not match required format \`${wheresSegment}\` for route \`"${this.name}"\`.` ); } } return encodeURI(isString(value) ? value : "").replace(/%7C/g, "|").replace(/%25/g, "%").replace(/\$/g, "%24"); } ); let finalUrl = url; if (this.config?.absolute && url.includes("://")) { const [protocol, rest] = url.split("://"); finalUrl = protocol + "://" + rest.replace(/\/{2,}/g, "/"); } else { finalUrl = url.replace(/\/{2,}/g, "/"); } return finalUrl === "/" ? "/" : finalUrl.replace(/\/+$/, ""); } }; var { REPO } = CONFIG; var docsErrorLinkRepo = ` \u2139\uFE0F Learn more: \`${REPO.LINK}#importing-the-route-function\`.`; var RouterConfigClass = class _RouterConfigClass { absolute; url; port; defaults; routes; location; constructor(config) { this.absolute = config.absolute; this.url = config.url; this.port = config.port; this.defaults = config.defaults; this.routes = config.routes; this.location = config.location; } /** Check object like RouterConfig * * @internal */ static isRouterConfigRaw(obj) { if (!isPlainObject(obj)) return false; if (!isPlainObject(obj)) { throw new RouterConfigError( `Invalid \`route()\` the \`config\` property at (fourth parameter) detected: - Ensure that \`appRoutes\` is defined globally or passed as a valid config object, but received: \`${getPreciseType(obj)}\`, with value: \`${realValue(obj)}\`.${docsErrorLinkRepo}` ); } if (!isString(obj.url)) { throw new RouterConfigError( `Invalid \`route()\` parameter \`config.url\` property of the \`config\` (fourth parameter) detected: - Ensure that \`appRoutes\` is defined globally or passed as a valid \`config.url\`. - Parameter \`config.url\` must be of type \`string\` or \`null\`, but received: \`${getPreciseType(obj.url)}\`.${docsErrorLinkRepo}` ); } if (!isNull(obj.port) && !isNumber(obj.port)) { throw new RouterConfigError( `Invalid \`route()\` parameter \`config.port\` property of the \`config\` (fourth parameter) detected: - Ensure that \`appRoutes\` is defined globally or passed as a valid \`config.port\`. - Parameter \`config.port\` must be of type \`number\` or \`null\`, but received: \`${getPreciseType(obj.port)}\`, with value: \`${realValue(obj.port)}\`.${docsErrorLinkRepo}` ); } if (!isPlainObject(obj.defaults)) { throw new RouterConfigError( `Invalid \`route()\` parameter \`config.defaults\` property of the \`config\` (fourth parameter) detected: - Ensure that \`appRoutes\` is defined globally or passed as a valid \`config.defaults\`. - Parameter \`config.defaults\` must be of type \`plain-object\` defaults property, but received: \`${getPreciseType(obj.defaults)}\`, with value: \`${realValue(obj.defaults)}\`.${docsErrorLinkRepo}` ); } if (!isPlainObject(obj.routes)) { throw new RouterConfigError( `Invalid \`route()\` parameter \`config.routes\` property of the \`config\` (fourth parameter) detected: - Ensure that \`appRoutes\` is defined globally or passed as a valid \`config.routes\`. - Parameter \`config.routes\` must be of type \`plain-object\` routes property, but received: \`${getPreciseType(obj.routes)}\`, with value: \`${realValue(obj.routes)}\`.${docsErrorLinkRepo}` ); } if (hasOwnProp(obj, "location")) { const loc = obj.location; if (!isPlainObject(loc)) { throw new RouterConfigError( `Invalid \`route()\` parameter \`config.location\` property of the \`config\` (fourth parameter) detected: - Ensure that \`appLocation\` is defined globally or passed as a valid \`config.location\`. - Parameter \`config.location\` must be of type \`plain-object ({ host?: string | undefined; pathname?: string | undefined; search?: string | undefined; })\` of location property, but received: \`${getPreciseType(loc)}\`, with value: \`${realValue(loc)}\`.${docsErrorLinkRepo}` ); } const l = loc; if (hasOwnProp(l, "host") && !isString(l.host)) { throw new RouterConfigError( `Invalid \`route()\` parameter \`config.location.host\` property of the \`config\` (fourth parameter) detected: - Ensure that \`appLocation\` is defined globally or passed as a valid \`config.location\`. - Parameter \`config.location.host\` must be of type \`string\` or \`undefined\`, but received: \`${getPreciseType(l.host)}\`, with value: \`${realValue(l.host)}\`.${docsErrorLinkRepo}` ); } if (hasOwnProp(l, "pathname") && !isString(l.pathname)) { throw new RouterConfigError( `Invalid \`route()\` parameter \`config.location.pathname\` property of the \`config\` (fourth parameter) detected: - Ensure that \`appLocation\` is defined globally or passed as a valid \`config.location\`. - Parameter \`config.location.pathname\` must be of type \`string\` or \`undefined\`, but received: \`${getPreciseType(l.pathname)}\`, with value: \`${realValue(l.pathname)}\`.${docsErrorLinkRepo}` ); } if (hasOwnProp(l, "search") && !isString(l.search)) { throw new RouterConfigError( `Invalid \`route()\` parameter \`config.location.search\` property of the \`config\` (fourth parameter) detected: - Ensure that \`appLocation\` is defined globally or passed as a valid \`config.location\`. - Parameter \`config.location.search\` must be of type \`string\` or \`undefined\`, but received: \`${getPreciseType(l.search)}\`, with value: \`${realValue(l.search)}\`.${docsErrorLinkRepo}` ); } } return true; } /** Validate & convert unknown object to RouterConfigClass * * @internal */ static validateAndWrap(obj) { try { if (obj instanceof _RouterConfigClass) return obj; if (!this.isRouterConfigRaw(obj)) { throw new RouterConfigError( `Invalid \`route()\` the \`config\` property at (fourth parameter) detected: - Ensure that \`appRoutes\` is defined globally or passed as a valid config object, but received: \`${getPreciseType(obj)}\`, with value: \`${realValue(obj)}\`.${docsErrorLinkRepo}` ); } return new _RouterConfigClass(obj); } catch (err) { if (err instanceof RouterConfigError) throw err; throw new RouterConfigError( `Invalid \`route()\` the \`config\` property at (fourth parameter) detected: - Ensure that \`appRoutes\` is defined globally or passed as a valid config object, but received: \`${getPreciseType(obj)}\`, with value: \`${realValue(obj)}\`.${docsErrorLinkRepo}`, isError(err) ? err : new Error(String(err)) ); } } }; var { REPO: REPO2, PACKAGE } = CONFIG; var Router = class extends String { _config; _route; _params = {}; constructor(name, params, absolute = false, config) { super(); if (!isNil(name) && isEmptyString(name)) { throw new RoutePropsError( `Invalid \`route()\`: - First parameter (\`name\`) must be of type a \`string\` and a non empty-string. - Use \`undefined\` if you don't want to provide a name. - Make sure to call a valid \`Router\` instance method, or you'll encounter an error. \u2139\uFE0F Learn more: \`${REPO2.LINK}#%EF%B8%8F-warning-calling-route-without-arguments\`.` ); } if (!isNull(absolute) && !isBoolean(absolute)) { throw new RoutePropsError( `Invalid \`route()\`: - Third parameter (\`absolute\`) must be of type a \`boolean\` or \`undefined\`, but received: \`${getPreciseType(absolute)}\`. \u2139\uFE0F Learn more: \`${REPO2.LINK}#absolute-url\`.` ); } absolute = !!absolute; const _config = this.safeValidateRouterConfig( config || isNull(config) ? config : typeof appRoutes !== "undefined" ? appRoutes : globalThis.appRoutes ); this._config = { ..._config, absolute }; if (name) { if (!this._config.routes[name]) { throw new RoutePropsError( `Route name \`"${name}"\` (first parameter) is not in the route list.` ); } this._route = new RouteFactory( name, this._config.routes[name], this._config ); this._params = this._parse(params); } } /** @internal */ safeValidateRouterConfig(obj) { try { return RouterConfigClass.validateAndWrap(obj); } catch (err) { if (err instanceof RouterConfigError) { throw err; } throw new Error( `${PACKAGE.PREFIX.NAME} - Unknown error while validating \`route()\` config`, { cause: isError(err) ? err : void 0 } ); } } /** @internal */ _unresolve(url) { if (!url) { url = this._currentUrl(); } else if (this._config.absolute && url.startsWith("/")) { url = this._location().host + url; } let matchedParams = {}; const [name, route2] = Object.entries(this._config.routes).find( ([name2, route3]) => matchedParams = new RouteFactory(name2, route3, this._config).matchesUrl( url ) ) || [void 0, void 0]; return { name, ...matchedParams, route: route2 }; } /** @internal */ _currentUrl() { const { host, pathname, search } = this._location(); return (this._config.absolute ? host + pathname : pathname.replace(this._config.url.replace(/^\w*:\/\/[^/]+/, ""), "").replace(/^\/+/, "/")) + search; } /** @internal */ _location() { const { host = "", pathname = "", search = "" } = !isServer() ? window.location : {}; return { host: this._config.location?.host ?? host, pathname: this._config.location?.pathname ?? pathname, search: this._config.location?.search ?? search }; } /** @internal */ _parse(params = {}, route2 = this._route) { if (!isPlainObject(params) && !isArray(params)) { throw new RoutePropsError( `Invalid \`route()\` \`params\` property detected. - Second Parameter (\`params\`) must be of type \`plain-object\` or \`array\`, but received: \`${getPreciseType(params)}\`. \u2139\uFE0F More info: ${REPO2.LINK}#parameters.` ); } params ??= {}; params = ["string", "number"].includes(typeof params) ? [params] : params; const segments = route2?.parameterSegments.filter( ({ name }) => !this._config.defaults[name] ); if (isArray(params)) { params = params.reduce( (result, current, i) => segments?.[i] ? { ...result, [segments[i].name]: current } : isPlainObject(current) || isArray(current) ? { ...result, ...current } : { ...result, ...isString(current) ? { [current]: "" } : {} }, {} ); } else if (segments?.length === 1 && !params[segments?.[0].name] && (route2 && params.hasOwnProperty(Object.values(route2.bindings)[0]) || params.hasOwnProperty("id"))) { params = { [segments?.[0].name]: params }; } return { ...this._defaults(route2), ...this._substituteBindings(params, route2) }; } /** * Populate default parameters for the given route. * * @example * // with default parameters { locale: 'en', country: 'US' } and 'posts.show' route '{locale}/posts/{post}' * defaults(...); // { locale: 'en' } * * @param {Route} route * @return {Object} Default route parameters. * * @internal */ _defaults(route2) { return route2?.parameterSegments.filter(({ name }) => this._config.defaults[name]).reduce( (result, { name }) => ({ ...result, [name]: this._config.defaults[name] }), {} ); } /** * Substitute Laravel route model bindings in the given parameters. * * @example * _substituteBindings({ post: { id: 4, slug: 'hello-world', title: 'Hello, world!' } }, { bindings: { post: 'slug' } }); // { post: 'hello-world' } * * @param {Object} params - Route parameters. * @param {Object} route - Route definition. * @return {Object} Normalized route parameters. * * @internal */ _substituteBindings(params, route2 = this._route) { return Object.entries(params).reduce((result, [key, value]) => { if (!value || typeof value !== "object" || isArray(value) || !route2?.parameterSegments.some(({ name }) => name === key)) { return { ...result, [key]: value }; } if (!value.hasOwnProperty(route2.bindings[key])) { if (value.hasOwnProperty("id")) { route2.bindings[key] = "id"; } else { throw new RoutePropsError( `Object passed as \`"${key}"\` parameter is missing route model binding key \`${route2.bindings[key]}\`. \u2139\uFE0F More info: ${REPO2.LINK}#parameters.` ); } } return { ...result, ...{ [key]: value[route2.bindings[key]] } }; }, {}); } get params() { const { params, query } = this._unresolve(); const flatQuery = {}; for (const key in query) { const val = query[key]; if (isString(val)) { flatQuery[key] = val; } else if (isArray(val)) { flatQuery[key] = val.filter(isString).join(","); } else if (isObject(val)) { flatQuery[key] = safeStableStringify(val); } } return { ...params, ...flatQuery }; } get routeParams() { return this._unresolve().params || {}; } get queryParams() { return this._unresolve().query || {}; } has(name) { if (!isString(name)) { throw new RoutePropsError( `Invalid \`route().has(...)\` parameter \`name\` detected. - First parameter (\`name\`) must be of type \`string\`, but received: \`${getPreciseType(name)}\`.` ); } return this._config.routes.hasOwnProperty(name); } current(name, params = {}) { if (name && !isString(name)) { throw new RoutePropsError( `Invalid \`route().current(...)\` parameter \`name\` detected. - First parameter (\`name\`) must be of type \`string\`, but received: \`${getPreciseType(name)}\`.` ); } if (params && !(isPlainObject(params) || isArray(params))) { throw new RoutePropsError( `Invalid parameter \`params\` (\`second parameter\`) value passed to \`route().current(...)\`, expected a object or array (e.g., { foo: "bar" } or [{"foo": "bar"}]), but received: \`${getPreciseType(params)}\`. \u2139\uFE0F Learn more: \`${REPO2.LINK}#routecurrent-optionally-accepts-parameters-as-its-second-argument-and-will-check-that-their-values-also-match-in-the-current-url\`.` ); } params ??= {}; const { name: current, params: currentParams, query, route: route2 } = this._unresolve(); if (!name) return current; const match = isString(current) && new RegExp(`^${name.replace(/\./g, "\\.").replace(/\*/g, ".*")}$`).test( current ); if (isNil(params) || !match) return match; const routeObject = new RouteFactory(current, route2, this._config); params = this._parse(params, routeObject); const routeParams = { ...currentParams, ...query }; if (Object.values(params).every((p) => !p) && !Object.values(routeParams).some((v) => !isUndefined(v))) { return true; } const isSubset = (subset, full) => { return Object.entries(subset).every(([key, value]) => { if (isArray(value) && isArray(full[key])) { const fKey = full[key]; return value.every((v) => fKey.includes(v)); } if (isObject(value) && isObject(full[key])) { return isSubset(value, full[key]); } return full[key] == value; }); }; return isSubset(params, routeParams); } toString() { const thisRoute = this._route; const thisParams = this._params ??= {}; if (!thisRoute) { throw new RoutePropsError( `Function route() was called without a \`name\` (first parameter) but used as a \`string\`. - Pass a valid route name, or use route().current() to get the current route name \u2014 or route().current('dashboard') to check if it matches. \u2139\uFE0F More info: ${REPO2.LINK}#%EF%B8%8F-warning-calling-route-without-arguments.` ); } const unhandled = Object.keys(thisParams).filter( (key) => !thisRoute.parameterSegments.some(({ name }) => name === key) ).filter((key) => key !== "_query").reduce( (result, current) => ({ ...result, [current]: thisParams[current] }), {} ); const thisParamsQuery = thisParams["_query"]; if (!isUndefined(thisParamsQuery) && !isPlainObject(thisParamsQuery)) { throw new RoutePropsError( `Invalid parameter \`_query\` property of the \`params\` (second parameter) value passed to \`route()\`, expected a \`plain-object\` (e.g., { foo: "bar" }), but received: \`${getPreciseType(thisParamsQuery)}\`. \u2139\uFE0F More info: ${REPO2.LINK}#query-parameters.` ); } return thisRoute.compile(thisParams) + stringify( { ...unhandled, ...thisParamsQuery }, { addQueryPrefix: true, arrayFormat: "indices", encodeValuesOnly: true, skipNulls: true, encoder: (value, encoder) => isBoolean(value) ? String(+value) : encoder(value) } ); } valueOf() { return this.toString(); } }; function route(name, params, absolute = false, config) { const router = new Router(name, params, absolute, config); return name ? router.toString() : router; } export { RouterConfigError, route };