@replohq/sdk 0.1.0 → 0.2.0
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.
- package/_vendor/replo-utils/lib/string.d.ts +43 -0
- package/_vendor/replo-utils/lib/url.d.ts +57 -0
- package/_vendor/schemas/money.mjs +1 -112
- package/_vendor/schemas/routing/match.d.ts +38 -0
- package/_vendor/schemas/routing/match.mjs +2 -0
- package/_vendor/schemas/routing/rules.d.ts +30 -0
- package/_vendor/schemas/routing/rules.mjs +96 -0
- package/chunk-5M7VU32I.mjs +115 -0
- package/chunk-VPEVERWE.mjs +63 -0
- package/package.json +9 -1
- package/routing/evaluate.d.ts +7 -0
- package/routing/evaluate.js +46 -0
- package/routing/evaluate.js.map +7 -0
- package/routing/rules.d.ts +12 -0
- package/routing/rules.js +5 -0
- package/routing/rules.js.map +7 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export declare const getValidFilename: (value: string) => string;
|
|
2
|
+
export declare function slugify(text: string): string;
|
|
3
|
+
export declare function metafieldContainsHtml(metafieldValue: string): boolean;
|
|
4
|
+
export declare function capitalizeFirstLetter(inputString: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Get url without query params.
|
|
7
|
+
*
|
|
8
|
+
* @author Kurt 2024-10-14
|
|
9
|
+
*/
|
|
10
|
+
export declare const getUrlWithoutQueryParams: (url: string) => string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Converts PascalCase strings to readable format with spaces.
|
|
13
|
+
* E.g., "LandingPage1" -> "Landing Page 1"
|
|
14
|
+
*
|
|
15
|
+
* @param pascalCaseString - The PascalCase string to convert
|
|
16
|
+
* @returns A readable string with spaces between words and numbers
|
|
17
|
+
*/
|
|
18
|
+
export declare const convertPascalCaseToReadable: (pascalCaseString: string) => string;
|
|
19
|
+
export declare function formatWithCatN({ content, startLineNumber, }: {
|
|
20
|
+
content: string;
|
|
21
|
+
startLineNumber?: number;
|
|
22
|
+
}): string;
|
|
23
|
+
/**
|
|
24
|
+
* Escapes special regex characters in a string for use in RegExp constructor
|
|
25
|
+
*/
|
|
26
|
+
export declare function escapeRegExp(string: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Skill names are authored as kebab-case slugs in SKILL.md frontmatter
|
|
29
|
+
* ("site-management", "founder-review"). Anywhere we render the name to the
|
|
30
|
+
* user — skills app cards, the chat tool feed, the provisioning overlay — we
|
|
31
|
+
* run it through this so they see "Site Management", "Founder Review", etc.
|
|
32
|
+
* instead of the raw slug.
|
|
33
|
+
*
|
|
34
|
+
* Names that already look human-authored (any whitespace or any uppercase
|
|
35
|
+
* letter) are returned untouched, so a skill can opt out by writing
|
|
36
|
+
* `name: My Custom Skill` in frontmatter.
|
|
37
|
+
*/
|
|
38
|
+
export declare function prettifySkillName(name: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Safely decodes a URI component, returning the original value if decoding fails.
|
|
41
|
+
* Returns null if the input is null or undefined, preserving empty strings.
|
|
42
|
+
*/
|
|
43
|
+
export declare function decodeURIComponentSafe(value: string | null | undefined): string | null;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a URLSearchParams from a record, omitting undefined values. Array
|
|
3
|
+
* values are appended as repeated keys; scalars are stringified.
|
|
4
|
+
*/
|
|
5
|
+
export declare function buildUrlSearchParams(values: Record<string, string | number | boolean | string[] | undefined>): URLSearchParams;
|
|
6
|
+
export declare function isValidHttpUrl(url: string, options?: {
|
|
7
|
+
allowBlockedHostnames?: boolean;
|
|
8
|
+
allowedHostnames?: string[];
|
|
9
|
+
}): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Tries to parse and validate the given string as a URL, including protocol-relative
|
|
12
|
+
* URLs. Returns a URL if parsing is possible, otherwise null.
|
|
13
|
+
*
|
|
14
|
+
* Note: Javascript URLs don't recognize protocol-relative URLs (e.g. //google.com)
|
|
15
|
+
* as valid, so we need special casing here. If protocol-relative, the returned
|
|
16
|
+
* url will have an https protocol. This default is arbitrary but backed up by best
|
|
17
|
+
* practice: https://webhint.io/docs/user-guide/hints/hint-no-protocol-relative-urls/
|
|
18
|
+
*/
|
|
19
|
+
export declare function normalizeUrlIncludingProtocolRelative(url: string): URL | null;
|
|
20
|
+
export declare function normalizeCustomDomain(input: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Returns true if `origin` is a valid Replo Vercel preview deployment origin.
|
|
23
|
+
* Matches `https://<slug>.preview.replointernal.com`, e.g.
|
|
24
|
+
*
|
|
25
|
+
* Use this when you have a full origin string (e.g. from `request.headers.origin`).
|
|
26
|
+
*/
|
|
27
|
+
export declare function isPreviewOrigin(origin: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Returns true if `hostname` belongs to a Replo Vercel preview deployment.
|
|
30
|
+
* Matches `<slug>.preview.replointernal.com`, e.g.
|
|
31
|
+
*
|
|
32
|
+
* Use this when you've already parsed the URL and have a bare hostname.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isPreviewInternalHostname(hostname: string): boolean;
|
|
35
|
+
export declare function normalizeUrlScheme(url: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Truncates a domain name by shortening the subdomain portion while preserving
|
|
38
|
+
* the main domain suffix. Useful for displaying long domain names in UI.
|
|
39
|
+
*
|
|
40
|
+
* @param domain - The domain to truncate (e.g., "very-long-subdomain.example.com")
|
|
41
|
+
* @param maxLength - Maximum length of the truncated domain (default: 40)
|
|
42
|
+
* @returns Truncated domain with ellipsis (e.g., "very-long-sub...example.com")
|
|
43
|
+
*/
|
|
44
|
+
export declare function truncateDomain(domain: string, maxLength?: number): string;
|
|
45
|
+
/**
|
|
46
|
+
* Validates that a route path is properly formatted and URL-safe.
|
|
47
|
+
* Routes must start with / and contain only valid slug segments.
|
|
48
|
+
*
|
|
49
|
+
* @param path - The route path to validate
|
|
50
|
+
* @returns true if the path is a valid route, false otherwise
|
|
51
|
+
*/
|
|
52
|
+
export declare const isValidRoutePath: (path: string) => boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Parses a URL, returning null instead of throwing. Pass `base` to resolve a
|
|
55
|
+
* relative value such as a site path.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseUrl(value: string, base?: string): URL | null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { ReploError } from '../../chunk-5M7VU32I.mjs';
|
|
1
2
|
import { __commonJS, __toESM } from '../../chunk-UJCSKKID.mjs';
|
|
2
3
|
import { z } from 'zod';
|
|
3
|
-
import { v4 } from 'uuid';
|
|
4
4
|
|
|
5
5
|
// ../../node_modules/.pnpm/first-match@0.0.1/node_modules/first-match/index.js
|
|
6
6
|
var require_first_match = __commonJS({
|
|
@@ -1859,117 +1859,6 @@ var require_currency_codes = __commonJS({
|
|
|
1859
1859
|
|
|
1860
1860
|
// ../schemas/money.ts
|
|
1861
1861
|
var import_currency_codes = __toESM(require_currency_codes());
|
|
1862
|
-
function randomUUID() {
|
|
1863
|
-
return v4();
|
|
1864
|
-
}
|
|
1865
|
-
var ReploError = class _ReploError extends Error {
|
|
1866
|
-
config;
|
|
1867
|
-
id;
|
|
1868
|
-
constructor(config) {
|
|
1869
|
-
super(config.message);
|
|
1870
|
-
this.message = config.message ?? this.constructor.name;
|
|
1871
|
-
this.config = config;
|
|
1872
|
-
this.id = randomUUID();
|
|
1873
|
-
const cause = config.cause;
|
|
1874
|
-
if (cause) {
|
|
1875
|
-
this.cause = cause;
|
|
1876
|
-
if (cause instanceof _ReploError) {
|
|
1877
|
-
this.config.additionalData = {
|
|
1878
|
-
...this.config.additionalData,
|
|
1879
|
-
...cause.config.additionalData
|
|
1880
|
-
};
|
|
1881
|
-
}
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
};
|
|
1885
|
-
var errorUserFacingDetailsMessageSchema = z.object({
|
|
1886
|
-
type: z.enum([
|
|
1887
|
-
"toast",
|
|
1888
|
-
"fullPageModal",
|
|
1889
|
-
"shopifyStorePassword",
|
|
1890
|
-
"billing.addPublishedElements",
|
|
1891
|
-
"billing.addShopifyThemeProject",
|
|
1892
|
-
"billing.addReploSite",
|
|
1893
|
-
// Agent Billing ONLY
|
|
1894
|
-
"agentBilling.shopifyThemeBuilder.publishedElements.exceeded",
|
|
1895
|
-
"agentBilling.shopifyThemeBuilder.projects.exceeded",
|
|
1896
|
-
"agentBilling.sites.exceeded",
|
|
1897
|
-
"agentBilling.credits.exceeded",
|
|
1898
|
-
"agentBilling.upgradeRequired"
|
|
1899
|
-
]),
|
|
1900
|
-
message: z.string(),
|
|
1901
|
-
isRecoverable: z.boolean().optional(),
|
|
1902
|
-
// NOTE (Matt 2024-03-22): We support detail being an array of strings
|
|
1903
|
-
// in FullPageModal only.
|
|
1904
|
-
detail: z.array(z.string()).or(z.string()).optional(),
|
|
1905
|
-
key: z.string(),
|
|
1906
|
-
usage: z.object({
|
|
1907
|
-
current: z.number(),
|
|
1908
|
-
maximum: z.number()
|
|
1909
|
-
}).optional(),
|
|
1910
|
-
plan: z.string().optional(),
|
|
1911
|
-
callToAction: z.discriminatedUnion("type", [
|
|
1912
|
-
z.object({ type: z.literal("reload") }),
|
|
1913
|
-
z.object({ type: z.literal("installApp") }),
|
|
1914
|
-
z.object({ type: z.literal("goToHomepage") }),
|
|
1915
|
-
z.object({ type: z.literal("navigateToProjectEditor") }),
|
|
1916
|
-
z.object({ type: z.literal("closeModal") }),
|
|
1917
|
-
z.object({
|
|
1918
|
-
type: z.literal("navigateToLink"),
|
|
1919
|
-
link: z.discriminatedUnion("type", [
|
|
1920
|
-
z.object({ type: z.literal("errors.wrongJsonExtension") }),
|
|
1921
|
-
z.object({ type: z.literal("errors.chunkingError") }),
|
|
1922
|
-
z.object({ type: z.literal("errors.indexBackupError") }),
|
|
1923
|
-
z.object({
|
|
1924
|
-
type: z.literal("errors.shopifyIntegrationAlreadyAssigned")
|
|
1925
|
-
}),
|
|
1926
|
-
z.object({
|
|
1927
|
-
type: z.literal("errors.shopifyIntegrationMissingAccessScopes"),
|
|
1928
|
-
url: z.string()
|
|
1929
|
-
}),
|
|
1930
|
-
z.object({
|
|
1931
|
-
type: z.literal("customLink"),
|
|
1932
|
-
url: z.string()
|
|
1933
|
-
}),
|
|
1934
|
-
z.object({
|
|
1935
|
-
type: z.literal(
|
|
1936
|
-
"errors.projectConnectedToBillingIntegrationForShopifyWorkspace"
|
|
1937
|
-
)
|
|
1938
|
-
}),
|
|
1939
|
-
z.object({ type: z.literal("errors.themeAssetSizeLimitExceeded") }),
|
|
1940
|
-
z.object({ type: z.literal("errors.themeTotalSizeLimitExceeded") }),
|
|
1941
|
-
z.object({
|
|
1942
|
-
type: z.literal("errors.pageMetafieldDefinitionsChanged")
|
|
1943
|
-
}),
|
|
1944
|
-
z.object({ type: z.literal("settings.billing") })
|
|
1945
|
-
])
|
|
1946
|
-
})
|
|
1947
|
-
]).optional()
|
|
1948
|
-
}).describe("ErrorUserFacingDetailsWithMessage");
|
|
1949
|
-
z.discriminatedUnion("type", [
|
|
1950
|
-
/**
|
|
1951
|
-
* Normal errors, show error UI depending on the type
|
|
1952
|
-
*/
|
|
1953
|
-
errorUserFacingDetailsMessageSchema,
|
|
1954
|
-
/**
|
|
1955
|
-
* Errors which should be ignored by the frontend. This is uncommon, usually
|
|
1956
|
-
* you want to show an error in the UI, and if you don't want to then you catch
|
|
1957
|
-
* the error in your route.
|
|
1958
|
-
*/
|
|
1959
|
-
z.object({
|
|
1960
|
-
type: z.literal("uncommon_none"),
|
|
1961
|
-
key: z.string(),
|
|
1962
|
-
usage: z.object({
|
|
1963
|
-
current: z.number(),
|
|
1964
|
-
maximum: z.number()
|
|
1965
|
-
}).optional(),
|
|
1966
|
-
message: z.string().optional(),
|
|
1967
|
-
isRecoverable: z.boolean().optional(),
|
|
1968
|
-
detail: z.array(z.string()).or(z.string()).optional()
|
|
1969
|
-
})
|
|
1970
|
-
]).describe("ErrorUserFacingDetails");
|
|
1971
|
-
|
|
1972
|
-
// ../schemas/money.ts
|
|
1973
1862
|
var InvalidAmountError = class extends ReploError {
|
|
1974
1863
|
};
|
|
1975
1864
|
var UnsupportedCurrencyError = class extends ReploError {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Captured segment values, keyed by param name. */
|
|
2
|
+
type RouteParams = Record<string, string>;
|
|
3
|
+
/**
|
|
4
|
+
* The params captured from `pathname`, or null when the source does not match.
|
|
5
|
+
* An empty object means the source matched with nothing to capture.
|
|
6
|
+
*/
|
|
7
|
+
export declare function matchSource({ source, pathname, }: {
|
|
8
|
+
source: string;
|
|
9
|
+
pathname: string;
|
|
10
|
+
}): RouteParams | null;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a destination carries any `:param`, anywhere — including inside a
|
|
13
|
+
* query, which splitting on `/` would miss.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hasRouteParam(value: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Every `:param` in the string, in order, each written back with its modifier.
|
|
18
|
+
* The modifier is part of the identity: a source's `:page?` may capture
|
|
19
|
+
* nothing, so a destination's `:page` is not the same param.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getRouteParams(value: string): string[];
|
|
22
|
+
/**
|
|
23
|
+
* Substitutes captured params into a destination in one pass, so a substituted
|
|
24
|
+
* value is never rescanned as though it were itself a param. A param the
|
|
25
|
+
* request never captured collapses to nothing when it was optional, and is
|
|
26
|
+
* otherwise left in place for the caller to notice.
|
|
27
|
+
*/
|
|
28
|
+
export declare function applyParams({ value, params, }: {
|
|
29
|
+
value: string;
|
|
30
|
+
params: RouteParams;
|
|
31
|
+
}): string;
|
|
32
|
+
export interface ParamSegment {
|
|
33
|
+
name: string;
|
|
34
|
+
modifier: "*" | "+" | "?" | null;
|
|
35
|
+
}
|
|
36
|
+
/** Reads `:name`, `:name*`, `:name+`, or `:name?`; null for a literal segment. */
|
|
37
|
+
export declare function getParamSegment(segment: string): ParamSegment | null;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ReploError } from "../errors";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export declare class RedirectRuleValidationError extends ReploError {
|
|
4
|
+
}
|
|
5
|
+
export declare const redirectStatusSchema: z.ZodUnion<readonly [z.ZodLiteral<301>, z.ZodLiteral<302>, z.ZodLiteral<307>, z.ZodLiteral<308>]>;
|
|
6
|
+
export declare const redirectRuleSchema: z.ZodObject<{
|
|
7
|
+
source: z.ZodString;
|
|
8
|
+
destination: z.ZodString;
|
|
9
|
+
status: z.ZodUnion<readonly [z.ZodLiteral<301>, z.ZodLiteral<302>, z.ZodLiteral<307>, z.ZodLiteral<308>]>;
|
|
10
|
+
}, z.core.$strict>;
|
|
11
|
+
export type RedirectStatus = z.infer<typeof redirectStatusSchema>;
|
|
12
|
+
export type RedirectRule = z.infer<typeof redirectRuleSchema>;
|
|
13
|
+
/**
|
|
14
|
+
* Validates a redirect rule, returning it unchanged. Throws
|
|
15
|
+
* `RedirectRuleValidationError` carrying the first failing check's message.
|
|
16
|
+
*/
|
|
17
|
+
export declare function reploRedirect(input: RedirectRule): RedirectRule;
|
|
18
|
+
export interface RedirectConflict {
|
|
19
|
+
blockingIndex: number;
|
|
20
|
+
blockedIndex: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Rules evaluate first-match-wins, so an earlier rule that already matches a
|
|
24
|
+
* later rule's own source makes the later rule dead. Compared with the later
|
|
25
|
+
* source's route params replaced, since that is a concrete path the earlier rule
|
|
26
|
+
* would see at request time.
|
|
27
|
+
*/
|
|
28
|
+
export declare function findRedirectConflicts(rules: readonly {
|
|
29
|
+
source: string;
|
|
30
|
+
}[]): RedirectConflict[];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ReploError } from '../../../chunk-5M7VU32I.mjs';
|
|
2
|
+
import { getParamSegment, getRouteParams, hasRouteParam, matchSource } from '../../../chunk-VPEVERWE.mjs';
|
|
3
|
+
import '../../../chunk-UJCSKKID.mjs';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
// ../replo-utils/lib/url.ts
|
|
7
|
+
function parseUrl(value, base) {
|
|
8
|
+
try {
|
|
9
|
+
return new URL(value, base);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
var RedirectRuleValidationError = class extends ReploError {
|
|
15
|
+
};
|
|
16
|
+
var SITE_PATH_ORIGIN = "https://site.invalid";
|
|
17
|
+
var redirectStatusSchema = z.union([
|
|
18
|
+
z.literal(301),
|
|
19
|
+
z.literal(302),
|
|
20
|
+
z.literal(307),
|
|
21
|
+
z.literal(308)
|
|
22
|
+
]);
|
|
23
|
+
var sourceSchema = z.string().min(1).refine((source) => source.startsWith("/"), {
|
|
24
|
+
message: "must start with /"
|
|
25
|
+
}).refine((source) => source.split("/").every(hasValidWildcardUsage), {
|
|
26
|
+
message: '"*" is only meaningful on a param segment \u2014 write "/blog/:rest*", not "/blog/*"'
|
|
27
|
+
});
|
|
28
|
+
function hasValidWildcardUsage(segment) {
|
|
29
|
+
if (!segment.includes("*")) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return getParamSegment(segment)?.modifier === "*";
|
|
33
|
+
}
|
|
34
|
+
var destinationSchema = z.string().min(1).refine(
|
|
35
|
+
(destination) => {
|
|
36
|
+
const concrete = replaceRouteParamsWithSentinel(destination);
|
|
37
|
+
if (concrete.startsWith("/")) {
|
|
38
|
+
return parseUrl(concrete, SITE_PATH_ORIGIN)?.origin === SITE_PATH_ORIGIN;
|
|
39
|
+
}
|
|
40
|
+
const protocol = parseUrl(concrete)?.protocol;
|
|
41
|
+
return protocol === "http:" || protocol === "https:";
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
message: "must be a path on this site or an absolute HTTP(S) URL; a destination starting with / must stay on this site"
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
var redirectRuleSchema = z.object({
|
|
48
|
+
source: sourceSchema,
|
|
49
|
+
destination: destinationSchema,
|
|
50
|
+
status: redirectStatusSchema
|
|
51
|
+
}).strict().superRefine(({ source, destination }, context) => {
|
|
52
|
+
const captured = new Set(getRouteParams(source));
|
|
53
|
+
for (const routeParam of getRouteParams(destination)) {
|
|
54
|
+
if (!captured.has(routeParam)) {
|
|
55
|
+
context.addIssue({
|
|
56
|
+
code: "custom",
|
|
57
|
+
path: ["destination"],
|
|
58
|
+
message: `":${routeParam}" is not captured by this rule's source, so it would be redirected to literally`
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const concrete = replaceRouteParamsWithSentinel(destination);
|
|
63
|
+
if (hasRouteParam(destination) && concrete.startsWith("/") && matchSource({ source, pathname: concrete }) !== null) {
|
|
64
|
+
context.addIssue({
|
|
65
|
+
code: "custom",
|
|
66
|
+
path: ["destination"],
|
|
67
|
+
message: "destination is matched by this rule's own source, so the redirect would loop forever"
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
function reploRedirect(input) {
|
|
72
|
+
const parsed = redirectRuleSchema.safeParse(input);
|
|
73
|
+
if (parsed.success) {
|
|
74
|
+
return parsed.data;
|
|
75
|
+
}
|
|
76
|
+
const message = parsed.error.issues[0]?.message ?? "invalid redirect rule";
|
|
77
|
+
throw new RedirectRuleValidationError({ message });
|
|
78
|
+
}
|
|
79
|
+
function findRedirectConflicts(rules) {
|
|
80
|
+
const conflicts = [];
|
|
81
|
+
rules.forEach((blocked, blockedIndex) => {
|
|
82
|
+
const pathname = replaceRouteParamsWithSentinel(blocked.source);
|
|
83
|
+
const blockingIndex = rules.findIndex((blocking, index) => {
|
|
84
|
+
return index < blockedIndex && matchSource({ source: blocking.source, pathname }) !== null;
|
|
85
|
+
});
|
|
86
|
+
if (blockingIndex >= 0) {
|
|
87
|
+
conflicts.push({ blockingIndex, blockedIndex });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return conflicts;
|
|
91
|
+
}
|
|
92
|
+
function replaceRouteParamsWithSentinel(value) {
|
|
93
|
+
return value.split("/").map((segment) => getParamSegment(segment) ? "x" : segment).join("/");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { RedirectRuleValidationError, findRedirectConflicts, redirectRuleSchema, redirectStatusSchema, reploRedirect };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { v4 } from 'uuid';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// ../replo-utils/lib/crypto.ts
|
|
5
|
+
function randomUUID() {
|
|
6
|
+
return v4();
|
|
7
|
+
}
|
|
8
|
+
var ReploError = class _ReploError extends Error {
|
|
9
|
+
config;
|
|
10
|
+
id;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
super(config.message);
|
|
13
|
+
this.message = config.message ?? this.constructor.name;
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.id = randomUUID();
|
|
16
|
+
const cause = config.cause;
|
|
17
|
+
if (cause) {
|
|
18
|
+
this.cause = cause;
|
|
19
|
+
if (cause instanceof _ReploError) {
|
|
20
|
+
this.config.additionalData = {
|
|
21
|
+
...this.config.additionalData,
|
|
22
|
+
...cause.config.additionalData
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var errorUserFacingDetailsMessageSchema = z.object({
|
|
29
|
+
type: z.enum([
|
|
30
|
+
"toast",
|
|
31
|
+
"fullPageModal",
|
|
32
|
+
"shopifyStorePassword",
|
|
33
|
+
"billing.addPublishedElements",
|
|
34
|
+
"billing.addShopifyThemeProject",
|
|
35
|
+
"billing.addReploSite",
|
|
36
|
+
// Agent Billing ONLY
|
|
37
|
+
"agentBilling.shopifyThemeBuilder.publishedElements.exceeded",
|
|
38
|
+
"agentBilling.shopifyThemeBuilder.projects.exceeded",
|
|
39
|
+
"agentBilling.sites.exceeded",
|
|
40
|
+
"agentBilling.credits.exceeded",
|
|
41
|
+
"agentBilling.upgradeRequired"
|
|
42
|
+
]),
|
|
43
|
+
message: z.string(),
|
|
44
|
+
isRecoverable: z.boolean().optional(),
|
|
45
|
+
// NOTE (Matt 2024-03-22): We support detail being an array of strings
|
|
46
|
+
// in FullPageModal only.
|
|
47
|
+
detail: z.array(z.string()).or(z.string()).optional(),
|
|
48
|
+
key: z.string(),
|
|
49
|
+
usage: z.object({
|
|
50
|
+
current: z.number(),
|
|
51
|
+
maximum: z.number()
|
|
52
|
+
}).optional(),
|
|
53
|
+
plan: z.string().optional(),
|
|
54
|
+
callToAction: z.discriminatedUnion("type", [
|
|
55
|
+
z.object({ type: z.literal("reload") }),
|
|
56
|
+
z.object({ type: z.literal("installApp") }),
|
|
57
|
+
z.object({ type: z.literal("goToHomepage") }),
|
|
58
|
+
z.object({ type: z.literal("navigateToProjectEditor") }),
|
|
59
|
+
z.object({ type: z.literal("closeModal") }),
|
|
60
|
+
z.object({
|
|
61
|
+
type: z.literal("navigateToLink"),
|
|
62
|
+
link: z.discriminatedUnion("type", [
|
|
63
|
+
z.object({ type: z.literal("errors.wrongJsonExtension") }),
|
|
64
|
+
z.object({ type: z.literal("errors.chunkingError") }),
|
|
65
|
+
z.object({ type: z.literal("errors.indexBackupError") }),
|
|
66
|
+
z.object({
|
|
67
|
+
type: z.literal("errors.shopifyIntegrationAlreadyAssigned")
|
|
68
|
+
}),
|
|
69
|
+
z.object({
|
|
70
|
+
type: z.literal("errors.shopifyIntegrationMissingAccessScopes"),
|
|
71
|
+
url: z.string()
|
|
72
|
+
}),
|
|
73
|
+
z.object({
|
|
74
|
+
type: z.literal("customLink"),
|
|
75
|
+
url: z.string()
|
|
76
|
+
}),
|
|
77
|
+
z.object({
|
|
78
|
+
type: z.literal(
|
|
79
|
+
"errors.projectConnectedToBillingIntegrationForShopifyWorkspace"
|
|
80
|
+
)
|
|
81
|
+
}),
|
|
82
|
+
z.object({ type: z.literal("errors.themeAssetSizeLimitExceeded") }),
|
|
83
|
+
z.object({ type: z.literal("errors.themeTotalSizeLimitExceeded") }),
|
|
84
|
+
z.object({
|
|
85
|
+
type: z.literal("errors.pageMetafieldDefinitionsChanged")
|
|
86
|
+
}),
|
|
87
|
+
z.object({ type: z.literal("settings.billing") })
|
|
88
|
+
])
|
|
89
|
+
})
|
|
90
|
+
]).optional()
|
|
91
|
+
}).describe("ErrorUserFacingDetailsWithMessage");
|
|
92
|
+
z.discriminatedUnion("type", [
|
|
93
|
+
/**
|
|
94
|
+
* Normal errors, show error UI depending on the type
|
|
95
|
+
*/
|
|
96
|
+
errorUserFacingDetailsMessageSchema,
|
|
97
|
+
/**
|
|
98
|
+
* Errors which should be ignored by the frontend. This is uncommon, usually
|
|
99
|
+
* you want to show an error in the UI, and if you don't want to then you catch
|
|
100
|
+
* the error in your route.
|
|
101
|
+
*/
|
|
102
|
+
z.object({
|
|
103
|
+
type: z.literal("uncommon_none"),
|
|
104
|
+
key: z.string(),
|
|
105
|
+
usage: z.object({
|
|
106
|
+
current: z.number(),
|
|
107
|
+
maximum: z.number()
|
|
108
|
+
}).optional(),
|
|
109
|
+
message: z.string().optional(),
|
|
110
|
+
isRecoverable: z.boolean().optional(),
|
|
111
|
+
detail: z.array(z.string()).or(z.string()).optional()
|
|
112
|
+
})
|
|
113
|
+
]).describe("ErrorUserFacingDetails");
|
|
114
|
+
|
|
115
|
+
export { ReploError };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ../schemas/routing/match.ts
|
|
2
|
+
function matchSource({
|
|
3
|
+
source,
|
|
4
|
+
pathname
|
|
5
|
+
}) {
|
|
6
|
+
const trimmed = source.replace(/\/+$/, "") || "/";
|
|
7
|
+
let pattern;
|
|
8
|
+
try {
|
|
9
|
+
pattern = new URLPattern({ pathname: `${trimmed}{/}?` });
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const match = pattern.exec({
|
|
14
|
+
pathname: pathname.split(/[?#]/)[0] ?? pathname
|
|
15
|
+
});
|
|
16
|
+
if (!match) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const params = {};
|
|
20
|
+
for (const [name, value] of Object.entries(match.pathname.groups)) {
|
|
21
|
+
if (/^\d+$/.test(name)) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
params[name] = value ?? "";
|
|
25
|
+
}
|
|
26
|
+
return params;
|
|
27
|
+
}
|
|
28
|
+
var ROUTE_PARAM = /:([A-Za-z0-9_.-]+?)([*+?]?)(?=[/?#]|$)/g;
|
|
29
|
+
function hasRouteParam(value) {
|
|
30
|
+
return new RegExp(ROUTE_PARAM.source).test(value);
|
|
31
|
+
}
|
|
32
|
+
function getRouteParams(value) {
|
|
33
|
+
const matches = value.matchAll(new RegExp(ROUTE_PARAM.source, "g"));
|
|
34
|
+
return [...matches].flatMap(([, name, modifier]) => {
|
|
35
|
+
return name ? [`${name}${modifier ?? ""}`] : [];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function applyParams({
|
|
39
|
+
value,
|
|
40
|
+
params
|
|
41
|
+
}) {
|
|
42
|
+
return value.replace(ROUTE_PARAM, (routeParam, name, modifier) => {
|
|
43
|
+
const captured = params[name];
|
|
44
|
+
if (captured !== void 0) {
|
|
45
|
+
return captured;
|
|
46
|
+
}
|
|
47
|
+
return modifier === "*" || modifier === "?" ? "" : routeParam;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
var PARAM_SEGMENT = /^:([A-Za-z0-9_.-]+)([*+?]?)$/;
|
|
51
|
+
function getParamSegment(segment) {
|
|
52
|
+
const match = PARAM_SEGMENT.exec(segment);
|
|
53
|
+
if (!match?.[1]) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const modifier = match[2];
|
|
57
|
+
return {
|
|
58
|
+
name: match[1],
|
|
59
|
+
modifier: modifier === "*" || modifier === "+" || modifier === "?" ? modifier : null
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export { applyParams, getParamSegment, getRouteParams, hasRouteParam, matchSource };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@replohq/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Replo SDK — cart, analytics, and data loaders for agent-built Next.js sites.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -186,6 +186,14 @@
|
|
|
186
186
|
"types": "./next/open-next.d.ts",
|
|
187
187
|
"default": "./next/open-next.js"
|
|
188
188
|
},
|
|
189
|
+
"./routing/evaluate": {
|
|
190
|
+
"types": "./routing/evaluate.d.ts",
|
|
191
|
+
"default": "./routing/evaluate.js"
|
|
192
|
+
},
|
|
193
|
+
"./routing/rules": {
|
|
194
|
+
"types": "./routing/rules.d.ts",
|
|
195
|
+
"default": "./routing/rules.js"
|
|
196
|
+
},
|
|
189
197
|
"./_vendor/schemas/loaderKeys": {
|
|
190
198
|
"types": "./_vendor/schemas/loaderKeys.d.ts",
|
|
191
199
|
"default": "./_vendor/schemas/loaderKeys.mjs"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import type { RedirectRule } from "../_vendor/schemas/routing/rules";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
export declare function evaluateRouting({ request, rules, }: {
|
|
5
|
+
request: NextRequest;
|
|
6
|
+
rules: readonly RedirectRule[];
|
|
7
|
+
}): NextResponse;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { applyParams, matchSource } from "../_vendor/schemas/routing/match.mjs";
|
|
3
|
+
function evaluateRouting({
|
|
4
|
+
request,
|
|
5
|
+
rules
|
|
6
|
+
}) {
|
|
7
|
+
for (const rule of rules) {
|
|
8
|
+
const response = evaluateRedirect({ request, rule });
|
|
9
|
+
if (response) {
|
|
10
|
+
return response;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return NextResponse.next();
|
|
14
|
+
}
|
|
15
|
+
function evaluateRedirect({
|
|
16
|
+
request,
|
|
17
|
+
rule
|
|
18
|
+
}) {
|
|
19
|
+
const params = matchSource({
|
|
20
|
+
source: rule.source,
|
|
21
|
+
pathname: request.nextUrl.pathname
|
|
22
|
+
});
|
|
23
|
+
if (!params) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const url = new URL(
|
|
27
|
+
applyParams({ value: rule.destination, params }),
|
|
28
|
+
request.nextUrl.origin
|
|
29
|
+
);
|
|
30
|
+
if (url.origin === request.nextUrl.origin && !url.search) {
|
|
31
|
+
url.search = request.nextUrl.search;
|
|
32
|
+
}
|
|
33
|
+
const redirectsToCurrentRequest = url.origin === request.nextUrl.origin && normalizePath(url.pathname) === normalizePath(request.nextUrl.pathname) && url.search === request.nextUrl.search;
|
|
34
|
+
if (redirectsToCurrentRequest) {
|
|
35
|
+
return NextResponse.next();
|
|
36
|
+
}
|
|
37
|
+
return NextResponse.redirect(url, rule.status);
|
|
38
|
+
}
|
|
39
|
+
function normalizePath(pathname) {
|
|
40
|
+
const trimmed = pathname.replace(/\/+$/, "");
|
|
41
|
+
return trimmed === "" ? "/" : trimmed;
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
evaluateRouting
|
|
45
|
+
};
|
|
46
|
+
//# sourceMappingURL=evaluate.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../routing/evaluate.ts"],
|
|
4
|
+
"sourcesContent": ["import type { NextRequest } from \"next/server\";\nimport type { RedirectRule } from \"schemas/routing/rules\";\n\nimport { NextResponse } from \"next/server\";\n\nimport { applyParams, matchSource } from \"schemas/routing/match\";\n\nexport function evaluateRouting({\n request,\n rules,\n}: {\n request: NextRequest;\n rules: readonly RedirectRule[];\n}): NextResponse {\n for (const rule of rules) {\n const response = evaluateRedirect({ request, rule });\n if (response) {\n return response;\n }\n }\n return NextResponse.next();\n}\n\nfunction evaluateRedirect({\n request,\n rule,\n}: {\n request: NextRequest;\n rule: RedirectRule;\n}): NextResponse | null {\n const params = matchSource({\n source: rule.source,\n pathname: request.nextUrl.pathname,\n });\n if (!params) {\n return null;\n }\n const url = new URL(\n applyParams({ value: rule.destination, params }),\n request.nextUrl.origin,\n );\n if (url.origin === request.nextUrl.origin && !url.search) {\n url.search = request.nextUrl.search;\n }\n // A destination that lands back on the current request would loop. The rule\n // still consumes the request \u2014 later rules were authored as alternatives to\n // it, not fallbacks for it \u2014 so the page renders normally instead.\n const redirectsToCurrentRequest =\n url.origin === request.nextUrl.origin &&\n normalizePath(url.pathname) === normalizePath(request.nextUrl.pathname) &&\n url.search === request.nextUrl.search;\n if (redirectsToCurrentRequest) {\n return NextResponse.next();\n }\n return NextResponse.redirect(url, rule.status);\n}\n\n/** Trailing slashes carry no meaning, so `/blogs` and `/blogs/` are the same. */\nfunction normalizePath(pathname: string): string {\n const trimmed = pathname.replace(/\\/+$/, \"\");\n return trimmed === \"\" ? \"/\" : trimmed;\n}\n"],
|
|
5
|
+
"mappings": "AAGA,SAAS,oBAAoB;AAE7B,SAAS,aAAa,mBAAmB;AAElC,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AACF,GAGiB;AACf,aAAW,QAAQ,OAAO;AACxB,UAAM,WAAW,iBAAiB,EAAE,SAAS,KAAK,CAAC;AACnD,QAAI,UAAU;AACZ,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO,aAAa,KAAK;AAC3B;AAEA,SAAS,iBAAiB;AAAA,EACxB;AAAA,EACA;AACF,GAGwB;AACtB,QAAM,SAAS,YAAY;AAAA,IACzB,QAAQ,KAAK;AAAA,IACb,UAAU,QAAQ,QAAQ;AAAA,EAC5B,CAAC;AACD,MAAI,CAAC,QAAQ;AACX,WAAO;AAAA,EACT;AACA,QAAM,MAAM,IAAI;AAAA,IACd,YAAY,EAAE,OAAO,KAAK,aAAa,OAAO,CAAC;AAAA,IAC/C,QAAQ,QAAQ;AAAA,EAClB;AACA,MAAI,IAAI,WAAW,QAAQ,QAAQ,UAAU,CAAC,IAAI,QAAQ;AACxD,QAAI,SAAS,QAAQ,QAAQ;AAAA,EAC/B;AAIA,QAAM,4BACJ,IAAI,WAAW,QAAQ,QAAQ,UAC/B,cAAc,IAAI,QAAQ,MAAM,cAAc,QAAQ,QAAQ,QAAQ,KACtE,IAAI,WAAW,QAAQ,QAAQ;AACjC,MAAI,2BAA2B;AAC7B,WAAO,aAAa,KAAK;AAAA,EAC3B;AACA,SAAO,aAAa,SAAS,KAAK,KAAK,MAAM;AAC/C;AAGA,SAAS,cAAc,UAA0B;AAC/C,QAAM,UAAU,SAAS,QAAQ,QAAQ,EAAE;AAC3C,SAAO,YAAY,KAAK,MAAM;AAChC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site-facing entrypoint for redirect rule authoring.
|
|
3
|
+
*
|
|
4
|
+
* The schema, validators, and matcher live in `schemas/routing` so the Website
|
|
5
|
+
* Builder can import them without depending on a published package, whose
|
|
6
|
+
* release cycle shouldn't gate editor-only changes. Sites can't import
|
|
7
|
+
* `schemas` directly — it is workspace-only and reaches generated sites through
|
|
8
|
+
* the SDK's vendoring step — so `middleware.ts` keeps importing `reploRedirect`
|
|
9
|
+
* from here.
|
|
10
|
+
*/
|
|
11
|
+
export type { RedirectRule, RedirectStatus, } from "../_vendor/schemas/routing/rules";
|
|
12
|
+
export { reploRedirect } from "../_vendor/schemas/routing/rules";
|
package/routing/rules.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../routing/rules.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Site-facing entrypoint for redirect rule authoring.\n *\n * The schema, validators, and matcher live in `schemas/routing` so the Website\n * Builder can import them without depending on a published package, whose\n * release cycle shouldn't gate editor-only changes. Sites can't import\n * `schemas` directly \u2014 it is workspace-only and reaches generated sites through\n * the SDK's vendoring step \u2014 so `middleware.ts` keeps importing `reploRedirect`\n * from here.\n */\nexport type {\n RedirectRule,\n RedirectStatus,\n} from \"schemas/routing/rules\"; /* eslint-disable-line replo/no-export-from -- site-facing entrypoint intentionally re-exports the authoring API. */\nexport { reploRedirect } from \"schemas/routing/rules\"; /* eslint-disable-line replo/no-export-from -- site-facing entrypoint intentionally re-exports the authoring API. */\n"],
|
|
5
|
+
"mappings": "AAcA,SAAS,qBAAqB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|