diff --git a/apps/web/src/components/import-from-deco-dialog.tsx b/apps/web/src/components/import-from-deco-dialog.tsx index 9a594a9740..cd2e11855b 100644 --- a/apps/web/src/components/import-from-deco-dialog.tsx +++ b/apps/web/src/components/import-from-deco-dialog.tsx @@ -12,8 +12,8 @@ import { useAutoInstallGitHub } from "@/hooks/use-auto-install-github"; import { useNavigateToAgent } from "@/hooks/use-navigate-to-agent"; import { resolveDecoSiteGithubRepo } from "@decocms/shared/deco-sites-github"; import { + defaultPreviewServerUrl, pickProductionDomain, - productionUrlFromDomain, } from "@decocms/shared/deco-site-production-url"; import { getOrgGithubConnections } from "@decocms/shared/github-repo-scope"; import { @@ -271,10 +271,8 @@ export function ImportFromDecoDialog({ const projectIcon = connBody.icon ?? null; const slug = generateSlug(siteName); const siteSlug = siteName.toLowerCase(); - // Default the preview server to the site's `{slug}.deco.site` host (legacy `productionUrl` dual-written for rollback). - const previewServerUrl = productionUrlFromDomain( - `${siteSlug}.deco.site`, - ); + // Default to the `{slug}.deco.site` host (legacy `productionUrl` dual-written for rollback). + const previewServerUrl = defaultPreviewServerUrl(siteName); // 2. Create a space (virtual MCP) wired to both admin-mcp and GitHub. const result = (await client.callTool({ diff --git a/packages/shared/src/deco-site-production-url.test.ts b/packages/shared/src/deco-site-production-url.test.ts index 7a13249a46..1a386a31db 100644 --- a/packages/shared/src/deco-site-production-url.test.ts +++ b/packages/shared/src/deco-site-production-url.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { + defaultPreviewServerUrl, pickProductionDomain, productionUrlFromDomain, resolvePreviewServerUrl, @@ -82,6 +83,25 @@ describe("productionUrlFromDomain", () => { }); }); +describe("defaultPreviewServerUrl", () => { + it("slugifies a bare site name into the deco.site host", () => { + expect(defaultPreviewServerUrl("acme")).toBe("https://acme.deco.site/"); + }); + + it("slugifies a display name with spaces and symbols", () => { + expect(defaultPreviewServerUrl("My Cool Site!")).toBe( + "https://my-cool-site.deco.site/", + ); + }); + + it("returns null for empty / whitespace-only / nullish, instead of throwing", () => { + expect(defaultPreviewServerUrl("")).toBeNull(); + expect(defaultPreviewServerUrl(" ")).toBeNull(); + expect(defaultPreviewServerUrl(null)).toBeNull(); + expect(defaultPreviewServerUrl(undefined)).toBeNull(); + }); +}); + describe("resolvePreviewServerUrl", () => { it("prefers previewServerUrl over the legacy productionUrl key", () => { expect( diff --git a/packages/shared/src/deco-site-production-url.ts b/packages/shared/src/deco-site-production-url.ts index 5f94aeaf56..36f76d6ad4 100644 --- a/packages/shared/src/deco-site-production-url.ts +++ b/packages/shared/src/deco-site-production-url.ts @@ -1,3 +1,5 @@ +import { slugify } from "./utils/slugify"; + /** * Site-URL helpers for linked deco.cx sites. * @@ -6,9 +8,9 @@ * by imports before the rename and still read as a fallback). It is usually * the live production site, but any deco-runtime deployment works (staging, a * local `https://localhost:3100`, ...), which is why the canonical name is - * "preview server". We deliberately do NOT derive it from `siteSlug` (the - * `{slug}.deco.site` guess) — a site's real URL can be a custom domain, so we - * persist what deco.cx actually reports at import time. + * "preview server". Imports default it to the site's `{slug}.deco.site` guess + * (see `defaultPreviewServerUrl`) rather than a reported custom domain, so the + * preview always targets the deco-runtime host. */ /** Validate/normalize a stored site URL. Returns the canonical href or `null`. */ @@ -67,3 +69,18 @@ export function productionUrlFromDomain( const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; return sanitizeSiteUrl(withProtocol); } + +/** + * Guess a site's deco.site preview host from its display name. `siteName` is + * a free-text title (shown as-is in the import picker), not a hostname, so it + * is slugified first — an unslugified name with a space or symbol makes + * `new URL()` throw, and `productionUrlFromDomain` swallows that into `null`, + * silently leaving the imported site with no preview server at all. + */ +export function defaultPreviewServerUrl( + siteName: string | null | undefined, +): string | null { + const slug = slugify(siteName ?? ""); + if (!slug) return null; + return productionUrlFromDomain(`${slug}.deco.site`); +}