From e78ef5e3dd1c32496f91f1f3fdd499123620d2f0 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:58:34 -0400 Subject: [PATCH] fix: accept full URLs in rank tracking domain input (#117) Replace DOMAIN_REGEX with URL-based normalization so users can paste full URLs (e.g. https://example.com/path) into the domain config form without getting a ZodError. The URL class handles protocol stripping, hostname extraction, and validation natively. --- .../rank-tracking/RankTrackingConfigModal.tsx | 2 + src/types/schemas/domain.ts | 38 +++++++++++++++---- src/types/schemas/rank-tracking.ts | 15 ++------ 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx index 4f530f1..87b93b9 100644 --- a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx +++ b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx @@ -10,6 +10,7 @@ import { Modal } from "@/client/components/Modal"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { captureClientEvent } from "@/client/lib/posthog"; import type { RankTrackingConfig } from "@/types/schemas/rank-tracking"; +import { normalizeDomain } from "@/types/schemas/domain"; import { depthToPages, pagesToDepth, @@ -155,6 +156,7 @@ export function RankTrackingConfigModal({ className="input input-bordered w-full" value={domain} onChange={(e) => setDomain(e.target.value)} + onBlur={() => setDomain(normalizeDomain(domain))} /> diff --git a/src/types/schemas/domain.ts b/src/types/schemas/domain.ts index 747ccfd..c562ff9 100644 --- a/src/types/schemas/domain.ts +++ b/src/types/schemas/domain.ts @@ -1,7 +1,35 @@ import { z } from "zod"; -export const DOMAIN_REGEX = - /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/; +/** + * Extract and validate a bare hostname from user input that may be a full URL. + * Strips protocol, www prefix, path, query-string, and hash. + */ +export function normalizeDomain(input: string): string { + let d = input.trim().toLowerCase(); + // Ensure URL() can parse the input by adding a protocol if missing + if (!/^[a-z]+:\/\//.test(d)) d = `https://${d}`; + const { hostname } = new URL(d); // throws on truly invalid input + return hostname.replace(/^www\./, ""); +} + +/** Zod field: accepts a bare domain or full URL, outputs a clean hostname. */ +export const domainField = z + .string() + .min(1) + .max(253) + .transform((val, ctx) => { + try { + const hostname = normalizeDomain(val); + if (!hostname.includes(".")) { + ctx.addIssue({ code: "custom", message: "Invalid domain format" }); + return z.NEVER; + } + return hostname; + } catch { + ctx.addIssue({ code: "custom", message: "Invalid domain format" }); + return z.NEVER; + } + }); const booleanSearchParamSchema = z .union([z.boolean(), z.enum(["true", "false"])]) @@ -25,11 +53,7 @@ const domainTabs = ["keywords", "pages"] as const; export const domainKeywordSuggestionsSchema = z.object({ projectId: z.string().uuid(), - domain: z - .string() - .min(1) - .max(253) - .regex(DOMAIN_REGEX, "Invalid domain format"), + domain: domainField, locationCode: z.number().int().positive(), languageCode: z.string().min(2).max(8), }); diff --git a/src/types/schemas/rank-tracking.ts b/src/types/schemas/rank-tracking.ts index 77e27d3..cff002b 100644 --- a/src/types/schemas/rank-tracking.ts +++ b/src/types/schemas/rank-tracking.ts @@ -1,7 +1,7 @@ import type { InferSelectModel } from "drizzle-orm"; import { z } from "zod"; import { rankTrackingConfigs } from "@/db/app.schema"; -import { DOMAIN_REGEX } from "@/types/schemas/domain"; +import { domainField } from "@/types/schemas/domain"; // --------------------------------------------------------------------------- // DB-derived types @@ -50,11 +50,7 @@ export const getConfigsSchema = z.object({ export const createConfigSchema = z.object({ projectId: z.string().uuid(), - domain: z - .string() - .min(1) - .max(253) - .regex(DOMAIN_REGEX, "Invalid domain format"), + domain: domainField, locationCode: z.number().int().positive().optional(), languageCode: z.string().max(10).optional(), devices: devicesEnum.optional(), @@ -65,12 +61,7 @@ export const createConfigSchema = z.object({ export const updateConfigSchema = z.object({ projectId: z.string().uuid(), configId: z.string().uuid(), - domain: z - .string() - .min(1) - .max(253) - .regex(DOMAIN_REGEX, "Invalid domain format") - .optional(), + domain: domainField.optional(), locationCode: z.number().int().positive().optional(), languageCode: z.string().max(10).optional(), devices: devicesEnum.optional(),