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.
This commit is contained in:
Ben Senescu 2026-04-16 02:58:34 -04:00 committed by GitHub
parent 86d3714846
commit e78ef5e3dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 36 additions and 19 deletions

View File

@ -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))}
/>
</div>

View File

@ -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),
});

View File

@ -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(),