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:
parent
86d3714846
commit
e78ef5e3dd
@ -10,6 +10,7 @@ import { Modal } from "@/client/components/Modal";
|
|||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
||||||
|
import { normalizeDomain } from "@/types/schemas/domain";
|
||||||
import {
|
import {
|
||||||
depthToPages,
|
depthToPages,
|
||||||
pagesToDepth,
|
pagesToDepth,
|
||||||
@ -155,6 +156,7 @@ export function RankTrackingConfigModal({
|
|||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
value={domain}
|
value={domain}
|
||||||
onChange={(e) => setDomain(e.target.value)}
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
onBlur={() => setDomain(normalizeDomain(domain))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,35 @@
|
|||||||
import { z } from "zod";
|
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
|
const booleanSearchParamSchema = z
|
||||||
.union([z.boolean(), z.enum(["true", "false"])])
|
.union([z.boolean(), z.enum(["true", "false"])])
|
||||||
@ -25,11 +53,7 @@ const domainTabs = ["keywords", "pages"] as const;
|
|||||||
|
|
||||||
export const domainKeywordSuggestionsSchema = z.object({
|
export const domainKeywordSuggestionsSchema = z.object({
|
||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid(),
|
||||||
domain: z
|
domain: domainField,
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(253)
|
|
||||||
.regex(DOMAIN_REGEX, "Invalid domain format"),
|
|
||||||
locationCode: z.number().int().positive(),
|
locationCode: z.number().int().positive(),
|
||||||
languageCode: z.string().min(2).max(8),
|
languageCode: z.string().min(2).max(8),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { rankTrackingConfigs } from "@/db/app.schema";
|
import { rankTrackingConfigs } from "@/db/app.schema";
|
||||||
import { DOMAIN_REGEX } from "@/types/schemas/domain";
|
import { domainField } from "@/types/schemas/domain";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DB-derived types
|
// DB-derived types
|
||||||
@ -50,11 +50,7 @@ export const getConfigsSchema = z.object({
|
|||||||
|
|
||||||
export const createConfigSchema = z.object({
|
export const createConfigSchema = z.object({
|
||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid(),
|
||||||
domain: z
|
domain: domainField,
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(253)
|
|
||||||
.regex(DOMAIN_REGEX, "Invalid domain format"),
|
|
||||||
locationCode: z.number().int().positive().optional(),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().max(10).optional(),
|
languageCode: z.string().max(10).optional(),
|
||||||
devices: devicesEnum.optional(),
|
devices: devicesEnum.optional(),
|
||||||
@ -65,12 +61,7 @@ export const createConfigSchema = z.object({
|
|||||||
export const updateConfigSchema = z.object({
|
export const updateConfigSchema = z.object({
|
||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid(),
|
||||||
configId: z.string().uuid(),
|
configId: z.string().uuid(),
|
||||||
domain: z
|
domain: domainField.optional(),
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(253)
|
|
||||||
.regex(DOMAIN_REGEX, "Invalid domain format")
|
|
||||||
.optional(),
|
|
||||||
locationCode: z.number().int().positive().optional(),
|
locationCode: z.number().int().positive().optional(),
|
||||||
languageCode: z.string().max(10).optional(),
|
languageCode: z.string().max(10).optional(),
|
||||||
devices: devicesEnum.optional(),
|
devices: devicesEnum.optional(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user