diff --git a/app/en/resources/contact-us/contact-cards.tsx b/app/en/resources/contact-us/contact-cards.tsx index 9e2e720a9..1c4cd5ad1 100644 --- a/app/en/resources/contact-us/contact-cards.tsx +++ b/app/en/resources/contact-us/contact-cards.tsx @@ -20,7 +20,7 @@ import { Users, } from "lucide-react"; import posthog from "posthog-js"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { QuickStartCard } from "../../../_components/quick-start-card"; @@ -264,6 +264,14 @@ function SuccessMessage({ onClose }: { onClose: () => void }) { export function ContactCards() { const [isSalesModalOpen, setIsSalesModalOpen] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); + // Assemble the support mailto only after mount so the SSR/crawled markup + // shows a plain contact-page link — Cloudflare's email obfuscation then has + // nothing to rewrite into a broken /cdn-cgi/l/email-protection link. Mirrors + // (see app/_components/contact-email.tsx). + const [supportHref, setSupportHref] = useState("/en/resources/contact-us"); + useEffect(() => { + setSupportHref("mailto:support@arcade.dev"); + }, []); const handleContactSalesClick = () => { posthog.capture("Contact sales modal opened", { @@ -282,7 +290,7 @@ export function ContactCards() {
diff --git a/next.config.ts b/next.config.ts index c0c5b2062..c3347c3d6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -31,6 +31,13 @@ const nextConfig: NextConfig = withLlmsTxt({ destination: "/:locale/resources/integrations", permanent: true, }, + // The auth provider is "square"; an external/stale link points at the + // old "squareup" slug, which 404s. Send it to the real page. + { + source: "/:locale/references/auth-providers/squareup", + destination: "/:locale/references/auth-providers/square", + permanent: true, + }, // Dissolved guides/security section { source: "/:locale/guides/security/security-research-program", diff --git a/tests/integration-index-links.test.ts b/tests/integration-index-links.test.ts index 637b46a2f..b2f5dd158 100644 --- a/tests/integration-index-links.test.ts +++ b/tests/integration-index-links.test.ts @@ -8,8 +8,13 @@ import { resolveIndexToolkits, toIntegrationLink, } from "@/app/_lib/integration-index"; -import type { ToolkitWithDocsLink } from "@/app/_lib/toolkit-slug"; +import { readToolkitData } from "@/app/_lib/toolkit-data"; import { + getToolkitSlug, + type ToolkitWithDocsLink, +} from "@/app/_lib/toolkit-slug"; +import { + getToolkitStaticParamsForCategory, INTEGRATION_CATEGORIES, listValidIntegrationLinks, } from "@/app/_lib/toolkit-static-params"; @@ -246,3 +251,98 @@ describe("hardcoded internal links in toolkit components resolve", () => { TIMEOUT ); }); + +// --------------------------------------------------------------------------- +// Toolkit page canonical hygiene +// +// docs' only page-level comes from the generated toolkit +// pages (toolkit-docs-page.tsx → generateMetadata, which emits +// `/en/resources/integrations//`). This guards +// that canonical class — the docs analog of the www canonical guard, and +// specifically the "Duplicate pages without canonical" finding MARTECH-19 fixed +// (notion): every toolkit page's canonical points at its own URL, canonicals are +// unique (no two pages share one), and none points at a redirect source or a +// non-generated route. We re-derive the canonical with the same pure helpers the +// page uses (static params + readToolkitData + getToolkitSlug) rather than +// importing the page module, which pulls in browser-only render code. +// +// (The docs sitemap — app/sitemap.ts, static MDX pages only — is guarded in +// tests/sitemap.test.ts: no redirect-source URLs, no duplicates.) +// --------------------------------------------------------------------------- + +describe("toolkit page canonical hygiene", () => { + let canonicals: Array<{ page: string; canonical: string }>; + let validLinks: Set; + let redirectSources: Set; + + beforeAll(async () => { + [validLinks, redirectSources] = await Promise.all([ + listValidIntegrationLinks(), + readRedirectSources(), + ]); + canonicals = []; + for (const category of INTEGRATION_CATEGORIES) { + for (const { toolkitId } of await getToolkitStaticParamsForCategory( + category + )) { + const data = await readToolkitData(toolkitId); + const canonical = data + ? `${INTEGRATIONS}/${category}/${getToolkitSlug({ + id: data.id, + docsLink: data.metadata?.docsLink, + })}` + : ""; + canonicals.push({ + page: `${INTEGRATIONS}/${category}/${toolkitId}`, + canonical, + }); + } + } + }, TIMEOUT); + + test("every generated toolkit page emits a canonical", () => { + expect(canonicals.length).toBeGreaterThan(0); + expect(canonicals.filter((c) => !c.canonical).map((c) => c.page)).toEqual( + [] + ); + }); + + test("each toolkit canonical points at the page's own URL", () => { + const mismatched = canonicals + .filter((c) => c.canonical && c.canonical !== c.page) + .map((c) => `${c.page} → ${c.canonical}`); + expect(mismatched).toEqual([]); + }); + + test("toolkit canonicals are unique (no duplicate-canonical pages)", () => { + const byCanonical = new Map(); + const duplicates: string[] = []; + for (const { page, canonical } of canonicals) { + if (!canonical) { + continue; + } + const prior = byCanonical.get(canonical); + if (prior) { + duplicates.push(`${canonical} ← ${prior} + ${page}`); + } else { + byCanonical.set(canonical, page); + } + } + expect(duplicates).toEqual([]); + }); + + test("no toolkit canonical points at a redirect or a missing route", () => { + const offenders: string[] = []; + for (const { canonical } of canonicals) { + if (!canonical) { + continue; + } + if (redirectSources.has(toLocaleParam(canonical))) { + offenders.push(`${canonical}: redirect source`); + } else if (!validLinks.has(withEnLocale(canonical))) { + offenders.push(`${canonical}: not a generated route`); + } + } + expect(offenders).toEqual([]); + }); +});