Skip to content

Commit 1d8608b

Browse files
juan-arcadedevJuan Ibarluceaclaude
authored
fix(seo): clear remaining docs audit errors (MARTECH-19) (#1007)
* fix(seo): clear remaining docs audit errors (MARTECH-19) Clears the broken-link / 404 / duplicate-canonical errors that remained after MARTECH-16: - Redirect /:locale/resources -> /:locale/resources/integrations (the toolkit breadcrumb's "Resources" link had no page). - Integrations index: drop bare "-api" duplicate cards, de-dupe same-URL entries (notion), and render doc-less catalog toolkits non-clickable instead of linking to 404s. - Add self-referential canonical to every toolkit page. - Fix the Square auth page link (squareupapi -> squareup-api). - Replace in-content mailto: links with a client-rendered <ContactEmail> so Cloudflare can't rewrite them into broken /cdn-cgi links (the <!--email_off--> marker is infeasible: it breaks the MDX build and the dangerouslySetInnerHTML workaround is a banned lint error). - Add tests/integration-index-links.test.ts to guard component-generated and dynamic-slug links, which the MDX-only scan missed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(seo): derive index-link assertions from live data (MARTECH-19) CI tests the PR merged into main, and main shipped real pages for several toolkits this PR treated as doc-less (datadog, vercel, ashby, customerio, apollo, discordbot, freshdesk). That made the hard-coded "datadog is a dropped duplicate" assertion false against the merged data. Rebuild the test to derive its cases from the live catalog + route set, and add a synthetic unit test that exercises the drop/dedup/hidden/ doc-less logic deterministically, so toolkit-data changes can't make it stale again. Also merges latest main into the branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(seo): generalize index-link tests, drop toolkit-name coupling Use placeholder toolkits in the resolveIndexToolkits unit tests and derive the static-page check from a filesystem scan instead of naming Tavily, so adding or removing a real toolkit (e.g. shipping Discord docs) never requires editing this test. Also drop the "some non-clickable" assertion, which would fail once every toolkit has its own page — an improvement, not a regression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(seo): document ContactEmail's hardcoded /en locale (review nit) Address PR review: note that CONTACT_PAGE is intentionally pinned to the English contact page (content is en-only today; a /<locale>/ link could 404), with a TODO to make it locale-aware once translated content ships. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Juan Ibarlucea <juanibarlucea@192.168.1.2> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c6b969d commit 1d8608b

16 files changed

Lines changed: 551 additions & 83 deletions

File tree

app/_components/contact-email.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import type { ReactNode } from "react";
5+
import { useEffect, useState } from "react";
6+
7+
// TODO(i18n): hardcoded to the English contact page. Docs content is en-only
8+
// today, so this is the safe target (a /<locale>/ link could 404 for locales
9+
// without the page). When translated content ships, derive the active locale
10+
// (e.g. via usePathname) and point at /<locale>/resources/contact-us.
11+
const CONTACT_PAGE = "/en/resources/contact-us";
12+
13+
type ContactEmailProps = {
14+
/** Local part of the address, e.g. "security" for security@arcade.dev. */
15+
user: string;
16+
/** Domain of the address, e.g. "arcade.dev". */
17+
domain: string;
18+
/** Visible link text. Keep it free of the raw address (see below). */
19+
children: ReactNode;
20+
};
21+
22+
/**
23+
* A mailto link whose address is assembled only after hydration.
24+
*
25+
* Cloudflare's Email Address Obfuscation runs at the edge on the server-rendered
26+
* HTML and rewrites any `mailto:`/address it finds into a
27+
* `/cdn-cgi/l/email-protection` link, which crawlers report as broken. By keeping
28+
* the address out of the SSR markup — we render a plain link to the contact page
29+
* until the component mounts — there is nothing for Cloudflare to rewrite, while
30+
* real users still get a working `mailto:`. Pass visible text via `children`
31+
* (not the literal address) so it isn't exposed server-side either.
32+
*/
33+
export function ContactEmail({ user, domain, children }: ContactEmailProps) {
34+
const [address, setAddress] = useState<string | null>(null);
35+
36+
useEffect(() => {
37+
setAddress(`${user}@${domain}`);
38+
}, [user, domain]);
39+
40+
if (address) {
41+
return <a href={`mailto:${address}`}>{children}</a>;
42+
}
43+
44+
return <Link href={CONTACT_PAGE}>{children}</Link>;
45+
}

app/_lib/integration-catalog.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Toolkit } from "@arcadeai/design-system";
2+
import { TOOLKITS } from "@arcadeai/design-system/metadata/toolkits";
3+
import { PARTNER_TOOLKITS } from "@/app/_data/partner-toolkits";
4+
import { readToolkitData } from "./toolkit-data";
5+
import { normalizeToolkitId, type ToolkitWithDocsLink } from "./toolkit-slug";
6+
7+
const getToolkitDocsLink = (toolkit: Toolkit): string | undefined => {
8+
if ("docsLink" in toolkit) {
9+
const value = (toolkit as ToolkitWithDocsLink).docsLink;
10+
return value ?? undefined;
11+
}
12+
return;
13+
};
14+
15+
/**
16+
* The full integrations catalog the index renders: design-system toolkits
17+
* (enriched with a `docsLink` from their data file when the catalog entry
18+
* lacks one, so the card's slug matches the generated page) plus docs-local
19+
* partner toolkits.
20+
*/
21+
export const getToolkitsWithDocsLinks = async (): Promise<
22+
ToolkitWithDocsLink[]
23+
> => {
24+
const docsLinkById = new Map<string, string>();
25+
26+
await Promise.all(
27+
TOOLKITS.map(async (toolkit) => {
28+
const existing = getToolkitDocsLink(toolkit);
29+
if (existing) {
30+
return;
31+
}
32+
33+
const data = await readToolkitData(toolkit.id);
34+
if (data?.metadata?.docsLink) {
35+
docsLinkById.set(
36+
normalizeToolkitId(toolkit.id),
37+
data.metadata.docsLink
38+
);
39+
}
40+
})
41+
);
42+
43+
const dsToolkits: ToolkitWithDocsLink[] = TOOLKITS.map((toolkit) => {
44+
const existing = getToolkitDocsLink(toolkit);
45+
const docsLink =
46+
existing ?? docsLinkById.get(normalizeToolkitId(toolkit.id));
47+
48+
return docsLink ? { ...toolkit, docsLink } : toolkit;
49+
});
50+
51+
return [...dsToolkits, ...PARTNER_TOOLKITS];
52+
};

app/_lib/integration-index.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { getToolkitSlug, type ToolkitWithDocsLink } from "./toolkit-slug";
2+
3+
const INTEGRATIONS_BASE = "/en/resources/integrations";
4+
5+
/**
6+
* The integrations link a toolkit card points to: `/en/resources/integrations/
7+
* <category>/<slug>`. Mirrors the slug + category logic used to generate the
8+
* dynamic `[toolkitId]` routes so cards and pages agree.
9+
*/
10+
export function toIntegrationLink(toolkit: {
11+
id: string;
12+
docsLink?: string | null;
13+
category?: string | null;
14+
}): string {
15+
const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink });
16+
const category = toolkit.category ?? "others";
17+
return `${INTEGRATIONS_BASE}/${category}/${slug}`;
18+
}
19+
20+
export type ResolvedIndexToolkit = ToolkitWithDocsLink & { hasPage: boolean };
21+
22+
/**
23+
* Decide which catalog toolkits the integrations index should render, and
24+
* whether each one links to a real page.
25+
*
26+
* The design-system catalog carries legacy bare-name entries (e.g. "Datadog"
27+
* alongside "DatadogApi") and doc-less placeholders (e.g. "Discord") that have
28+
* no generated docs page — linking to them 404s. Given the set of links that
29+
* actually resolve (`validLinks`: dynamic toolkit routes + authored static
30+
* pages), this:
31+
* - drops a bare entry when its `-api` sibling owns the real page (collapses
32+
* Datadog/DatadogApi, Vercel/VercelApi, Ashby/AshbyApi, Customerio/...),
33+
* - de-dupes entries that resolve to the same URL (e.g. Notion/NotionToolkit),
34+
* - flags the rest with `hasPage` so the caller can render doc-less toolkits
35+
* as non-clickable cards instead of as broken links.
36+
*/
37+
export function resolveIndexToolkits(
38+
toolkits: ToolkitWithDocsLink[],
39+
validLinks: ReadonlySet<string>
40+
): ResolvedIndexToolkit[] {
41+
const seen = new Set<string>();
42+
const resolved: ResolvedIndexToolkit[] = [];
43+
44+
for (const toolkit of toolkits) {
45+
// Hidden toolkits never render in the index (matches the client filter).
46+
if (toolkit.isHidden) {
47+
continue;
48+
}
49+
50+
const link = toIntegrationLink(toolkit);
51+
const hasPage = validLinks.has(link);
52+
53+
// A bare duplicate of a real "-api" toolkit: drop it; the real card stays.
54+
if (!hasPage && validLinks.has(`${link}-api`)) {
55+
continue;
56+
}
57+
58+
// Collapse multiple catalog entries that point at the same URL.
59+
if (seen.has(link)) {
60+
continue;
61+
}
62+
seen.add(link);
63+
resolved.push({ ...toolkit, hasPage });
64+
}
65+
66+
return resolved;
67+
}

app/_lib/toolkit-static-params.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,67 @@ export async function getToolkitStaticParamsForCategory(
186186
.filter((route) => route.category === category)
187187
.map((route) => ({ toolkitId: route.toolkitId }));
188188
}
189+
190+
const INTEGRATIONS_APP_DIR = join(
191+
process.cwd(),
192+
"app",
193+
"en",
194+
"resources",
195+
"integrations"
196+
);
197+
198+
const PAGE_FILE_NAMES = new Set(["page.mdx", "page.tsx"]);
199+
200+
/**
201+
* Authored static integration pages (e.g. partner pages like `search/tavily`
202+
* and `tool-feedback`) live next to the dynamic `[toolkitId]` routes. They are
203+
* real pages but are not part of `listToolkitRoutes`, so enumerate them from
204+
* disk under the known integration categories.
205+
*/
206+
const listStaticIntegrationLinks = async (): Promise<string[]> => {
207+
const links: string[] = [];
208+
209+
for (const category of INTEGRATION_CATEGORIES) {
210+
const categoryDir = join(INTEGRATIONS_APP_DIR, category);
211+
try {
212+
const slugs = await readdir(categoryDir, { withFileTypes: true });
213+
for (const slug of slugs) {
214+
if (!slug.isDirectory() || slug.name.startsWith("[")) {
215+
continue;
216+
}
217+
const files = await readdir(join(categoryDir, slug.name));
218+
if (files.some((file) => PAGE_FILE_NAMES.has(file))) {
219+
links.push(`/en/resources/integrations/${category}/${slug.name}`);
220+
}
221+
}
222+
} catch {
223+
// Category dir missing or unreadable — skip it.
224+
}
225+
}
226+
227+
return links;
228+
};
229+
230+
/**
231+
* The full set of links the integrations index may point at and that actually
232+
* resolve: dynamic toolkit routes plus authored static pages. Used to decide
233+
* whether a catalog card should be clickable.
234+
*/
235+
export async function listValidIntegrationLinks(options?: {
236+
dataDir?: string;
237+
toolkitsCatalog?: ToolkitCatalogEntry[];
238+
}): Promise<Set<string>> {
239+
const routes = await listToolkitRoutes(options);
240+
const links = new Set<string>(
241+
routes.map(
242+
(route) =>
243+
`/en/resources/integrations/${route.category}/${route.toolkitId}`
244+
)
245+
);
246+
247+
for (const staticLink of await listStaticIntegrationLinks()) {
248+
links.add(staticLink);
249+
}
250+
251+
return links;
252+
}

app/en/references/auth-providers/oauth2/page.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ description: Authorize tools and agents with any OAuth 2.0-compatible provider
55

66
import { Tabs, Callout, Steps } from "nextra/components";
77
import ToggleContent from "@/app/_components/toggle-content";
8+
import { ContactEmail } from "@/app/_components/contact-email";
89

910
# OAuth 2.0
1011

@@ -125,8 +126,11 @@ The ID of the provider (`hooli` in this example) can be any string. It's used to
125126
Each service expects a slightly different set of values in the `oauth2` section. Refer to the configuration reference below, and to your service's documentation to understand what values are required.
126127

127128
<Callout>
128-
If you need help configuring a specific provider, [get in touch with
129-
us](mailto:contact@arcade.dev)!
129+
If you need help configuring a specific provider,{" "}
130+
<ContactEmail domain="arcade.dev" user="contact">
131+
get in touch with us
132+
</ContactEmail>
133+
!
130134
</Callout>
131135

132136
</Steps>

app/en/references/auth-providers/page.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ description: Registry of all auth providers available in the Arcade ecosystem
66
# Auth Providers
77

88
import { ToolCard } from "@/app/_components/tool-card";
9+
import { ContactEmail } from "@/app/_components/contact-email";
910

1011
Auth providers enable users to seamlessly and securely allow Arcade tools to access their data.
1112

@@ -156,7 +157,7 @@ For more information on how to customize your auth provider, select an auth prov
156157
/>
157158
</div>
158159

159-
If the auth provider you need is not listed, try the [OAuth 2.0](/references/auth-providers/oauth2) provider, or [get in touch](mailto:contact@arcade.dev) with us!
160+
If the auth provider you need is not listed, try the [OAuth 2.0](/references/auth-providers/oauth2) provider, or <ContactEmail domain="arcade.dev" user="contact">get in touch</ContactEmail> with us!
160161

161162
## Using multiple providers of the same type
162163

app/en/references/auth-providers/square/page.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ The Square auth provider enables tools and agents to call [Square APIs](https://
66

77
<Callout>
88
Want to quickly get started with Square in your agent or AI app? The pre-built
9-
[Arcade Square MCP Server](/resources/integrations/productivity/squareupapi)
9+
[Arcade Square MCP Server](/resources/integrations/productivity/squareup-api)
1010
is what you want!
1111
</Callout>
1212

@@ -16,7 +16,7 @@ This page describes how to use and configure Square auth with Arcade.
1616

1717
This auth provider is used by:
1818

19-
- The [Arcade Square MCP Server](/resources/integrations/productivity/squareupapi), which provides pre-built tools for interacting with Square
19+
- The [Arcade Square MCP Server](/resources/integrations/productivity/squareup-api), which provides pre-built tools for interacting with Square
2020
- Your [app code](#using-square-auth-in-app-code) that needs to call the Square API
2121
- Or, your [custom tools](#using-square-auth-in-custom-tools) that need to call the Square API
2222

@@ -247,7 +247,7 @@ const token = authResponse.context.token;
247247

248248
## Using Square auth in custom tools
249249

250-
You can use the pre-built [Arcade Square MCP Server](/resources/integrations/productivity/squareupapi) to quickly build agents and AI apps that interact with Square.
250+
You can use the pre-built [Arcade Square MCP Server](/resources/integrations/productivity/squareup-api) to quickly build agents and AI apps that interact with Square.
251251

252252
If the pre-built tools in the Square MCP Server don't meet your needs, you can author your own [custom tools](/guides/create-tools/tool-basics/build-mcp-server) that interact with the Square API.
253253

app/en/resources/faq/page.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button } from "@arcadeai/design-system";
77
import { MessagesSquare } from "lucide-react";
88
import Link from "next/link";
99
import { Tabs } from "nextra/components";
10+
import { ContactEmail } from "@/app/_components/contact-email";
1011

1112
# Frequently Asked Questions
1213

@@ -42,7 +43,7 @@ This is an important observation. Technically, both included and custom provider
4243

4344
### Can my users connect multiple accounts of the same provider?
4445

45-
Please [contact us](mailto:support@arcade.dev) if you need this feature.
46+
Please <ContactEmail domain="arcade.dev" user="support">contact us</ContactEmail> if you need this feature.
4647

4748
### Can I authenticate multiple tools at once?
4849

app/en/resources/integrations/_lib/toolkit-docs-page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
22
import { notFound } from "next/navigation";
33
import { ToolkitPage } from "@/app/_components/toolkit-docs";
44
import { readToolkitData } from "@/app/_lib/toolkit-data";
5-
import { normalizeToolkitId } from "@/app/_lib/toolkit-slug";
5+
import { getToolkitSlug, normalizeToolkitId } from "@/app/_lib/toolkit-slug";
66
import {
77
getToolkitStaticParamsForCategory,
88
type IntegrationCategory,
@@ -43,9 +43,20 @@ export function createToolkitDocsPage(category: IntegrationCategory) {
4343
return {};
4444
}
4545

46+
// Canonicalize to the toolkit's preferred slug so any alias that resolves
47+
// to the same content (e.g. a normalized id vs. its docsLink slug) points
48+
// search engines at one URL.
49+
const canonicalSlug = getToolkitSlug({
50+
id: data.id,
51+
docsLink: data.metadata?.docsLink,
52+
});
53+
4654
return {
4755
title: data.label || data.id,
4856
description: data.description || "Generated MCP server documentation.",
57+
alternates: {
58+
canonical: `/en/resources/integrations/${category}/${canonicalSlug}`,
59+
},
4960
};
5061
};
5162

0 commit comments

Comments
 (0)