diff --git a/app/_lib/integration-index.ts b/app/_lib/integration-index.ts index 12d9cacbf..d0b34a39e 100644 --- a/app/_lib/integration-index.ts +++ b/app/_lib/integration-index.ts @@ -13,6 +13,9 @@ export function toIntegrationLink(toolkit: { category?: string | null; }): string { const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink }); + if (!slug) { + throw new Error(`Cannot build an integration link for: ${toolkit.id}`); + } const category = toolkit.category ?? "others"; return `${INTEGRATIONS_BASE}/${category}/${slug}`; } @@ -47,7 +50,12 @@ export function resolveIndexToolkits( continue; } - const link = toIntegrationLink(toolkit); + let link: string; + try { + link = toIntegrationLink(toolkit); + } catch { + continue; + } const hasPage = validLinks.has(link); // A bare duplicate of a real "-api" toolkit: drop it; the real card stays. diff --git a/app/_lib/toolkit-data.ts b/app/_lib/toolkit-data.ts index f0299c74e..af968e60f 100644 --- a/app/_lib/toolkit-data.ts +++ b/app/_lib/toolkit-data.ts @@ -1,5 +1,6 @@ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; +import { z } from "zod"; import type { ToolkitData, ToolkitSummary, @@ -65,14 +66,44 @@ const DEFAULT_DATA_DIR = join( const resolveDataDir = (options?: ToolkitDataOptions): string => options?.dataDir ?? process.env.TOOLKIT_DATA_DIR ?? DEFAULT_DATA_DIR; -const isValidToolkitData = (parsed: unknown): parsed is ToolkitData => - typeof parsed === "object" && - parsed !== null && - "id" in parsed && - ("label" in parsed || "name" in parsed) && - "metadata" in parsed && - typeof (parsed as Record).metadata === "object" && - (parsed as Record).metadata !== null; +// App-local Zod schemas (generator schemas live under toolkit-docs-generator and +// can't be imported here due to module resolution). Keep the index envelope +// loose so one bad entry is filtered instead of dropping the whole catalog. +const ToolkitIndexEntrySchema = z.object({ + id: z.string(), + label: z.string(), + version: z.string(), + category: z.string(), + type: z.string().optional(), + toolCount: z.number().int().nonnegative(), + authType: z.string(), +}); + +const ToolkitIndexEnvelopeSchema = z.object({ + generatedAt: z.string(), + version: z.string(), + toolkits: z.array(z.unknown()), +}); + +const ToolkitDataSchema = z + .object({ + id: z.string(), + label: z.string().optional(), + name: z.string().optional(), + metadata: z.object({}).passthrough(), + }) + .passthrough() + .refine((value) => value.label !== undefined || value.name !== undefined); + +const parseToolkitData = (parsed: unknown): ToolkitData | null => { + const result = ToolkitDataSchema.safeParse(parsed); + return result.success ? (result.data as ToolkitData) : null; +}; + +const parseToolkitIndexEntry = (value: unknown): ToolkitIndexEntry | null => { + const result = ToolkitIndexEntrySchema.safeParse(value); + return result.success ? result.data : null; +}; const readToolkitFile = async ( filePath: string @@ -80,7 +111,7 @@ const readToolkitFile = async ( try { const content = await readFile(filePath, "utf-8"); const parsed: unknown = JSON.parse(content); - return isValidToolkitData(parsed) ? (parsed as ToolkitData) : null; + return parseToolkitData(parsed); } catch { return null; } @@ -106,9 +137,9 @@ const findToolkitDataBySlug = async ( const candidateSlug = getToolkitSlug({ id: data.id, docsLink: data.metadata?.docsLink, - }).toLowerCase(); + })?.toLowerCase(); - if (candidateSlug === slugKey) { + if (candidateSlug && candidateSlug === slugKey) { return data; } } @@ -147,18 +178,20 @@ export const readToolkitIndex = async ( try { const content = await readFile(filePath, "utf-8"); const parsed: unknown = JSON.parse(content); + const envelope = ToolkitIndexEnvelopeSchema.safeParse(parsed); - // Basic runtime validation - ensure it's an object with required fields - if ( - typeof parsed !== "object" || - parsed === null || - !("toolkits" in parsed) || - !Array.isArray((parsed as { toolkits: unknown }).toolkits) - ) { + if (!envelope.success) { return null; } - return parsed as ToolkitIndex; + return { + generatedAt: envelope.data.generatedAt, + version: envelope.data.version, + toolkits: envelope.data.toolkits.flatMap((entry) => { + const parsedEntry = parseToolkitIndexEntry(entry); + return parsedEntry ? [parsedEntry] : []; + }), + }; } catch { return null; } diff --git a/app/_lib/toolkit-slug.ts b/app/_lib/toolkit-slug.ts index 5ff34e21d..f8939604b 100644 --- a/app/_lib/toolkit-slug.ts +++ b/app/_lib/toolkit-slug.ts @@ -2,6 +2,7 @@ import type { Toolkit } from "@arcadeai/design-system"; const TOOLKIT_ID_NORMALIZER = /[^a-z0-9]+/g; const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g; +const SAFE_SLUG = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/; export type ToolkitSlugSource = { id: string; @@ -43,10 +44,14 @@ export function toKebabCase(value: string): string { const extractSlugFromPath = (path: string): string | null => { const segments = path.split("/").filter(Boolean); - return segments.at(-1) ?? null; + const slug = segments.at(-1); + return slug && SAFE_SLUG.test(slug) ? slug : null; }; -export function getToolkitSlug({ id, docsLink }: ToolkitSlugSource): string { +export function getToolkitSlug({ + id, + docsLink, +}: ToolkitSlugSource): string | null { if (docsLink) { try { const url = new URL(docsLink); @@ -62,5 +67,10 @@ export function getToolkitSlug({ id, docsLink }: ToolkitSlugSource): string { } } - return toKebabCase(id); + const kebabId = toKebabCase(id); + if (SAFE_SLUG.test(kebabId)) { + return kebabId; + } + const normalizedId = normalizeToolkitId(id); + return normalizedId || null; } diff --git a/app/_lib/toolkit-static-params.ts b/app/_lib/toolkit-static-params.ts index 1e07c5c33..79e138464 100644 --- a/app/_lib/toolkit-static-params.ts +++ b/app/_lib/toolkit-static-params.ts @@ -31,6 +31,13 @@ export type ToolkitRouteEntry = { category: IntegrationCategory; }; +const sortToolkitRoutes = (routes: ToolkitRouteEntry[]): ToolkitRouteEntry[] => + routes.sort( + (left, right) => + left.category.localeCompare(right.category) || + left.toolkitId.localeCompare(right.toolkitId) + ); + const DESIGN_SYSTEM_TOOLKITS_FOR_ROUTES: ToolkitCatalogEntry[] = DESIGN_SYSTEM_TOOLKITS.map((toolkit) => ({ id: toolkit.id, @@ -70,6 +77,9 @@ export function getToolkitCanonicalPath(toolkit: { }): string { const category = normalizeCategory(toolkit.category); const slug = getToolkitSlug({ id: toolkit.id, docsLink: toolkit.docsLink }); + if (!slug) { + throw new Error(`Cannot build a canonical path for toolkit: ${toolkit.id}`); + } return `/en/resources/integrations/${category}/${slug}`; } @@ -118,6 +128,9 @@ const listToolkitRoutesFromDataDir = async (options?: { id: parsed.id, docsLink: parsed.metadata?.docsLink, }); + if (!slug) { + continue; + } const category = normalizeCategory(parsed.metadata?.category); unique.set(slug, { toolkitId: slug, category }); } catch { @@ -125,7 +138,7 @@ const listToolkitRoutesFromDataDir = async (options?: { } } - return [...unique.values()]; + return sortToolkitRoutes([...unique.values()]); }; const resolveToolkitRoute = async ( @@ -152,6 +165,9 @@ const resolveToolkitRoute = async ( id: toolkit.id, docsLink: data?.metadata?.docsLink ?? catalogEntry?.docsLink, }); + if (!slug) { + return null; + } // JSON file is the source of truth for category. The generator is responsible // for writing the correct value; the design system catalog and index.json are // only used as a last resort when the JSON is missing. @@ -192,7 +208,7 @@ export async function listToolkitRoutes(options?: { unique.set(route.toolkitId, route); } - return [...unique.values()]; + return sortToolkitRoutes([...unique.values()]); } export async function getToolkitStaticParamsForCategory( diff --git a/app/sitemap.ts b/app/sitemap.ts index 45b6615cf..0c7b0a9e9 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { MetadataRoute } from "next"; +import { listToolkitRoutes } from "./_lib/toolkit-static-params"; const SITE_URL = process.env.SITE_URL ?? "https://docs.arcade.dev"; const NORMALIZED_SITE_URL = SITE_URL.replace(/\/+$/, ""); @@ -43,11 +44,27 @@ async function collectRoutes(dir: string): Promise { return entries; } +async function collectToolkitRoutes(): Promise { + const routes = await listToolkitRoutes(); + return routes + .filter(({ category }) => category !== "others") + .map(({ category, toolkitId }) => ({ + url: `${NORMALIZED_SITE_URL}/en/resources/integrations/${category}/${toolkitId}`, + })); +} + export default function sitemap(): Promise { if (!cachedRoutes) { - cachedRoutes = collectRoutes(APP_DIR).then((routes) => { - routes.sort((a, b) => a.url.localeCompare(b.url)); - return routes; + cachedRoutes = Promise.all([ + collectToolkitRoutes(), + collectRoutes(APP_DIR), + ]).then(([toolkitRoutes, authoredRoutes]) => { + const routesByUrl = new Map( + [...toolkitRoutes, ...authoredRoutes].map((route) => [route.url, route]) + ); + return [...routesByUrl.values()].sort((a, b) => + a.url.localeCompare(b.url) + ); }); } diff --git a/tests/integration-index-links.test.ts b/tests/integration-index-links.test.ts index de50d8f9d..fa4bc7996 100644 --- a/tests/integration-index-links.test.ts +++ b/tests/integration-index-links.test.ts @@ -99,6 +99,16 @@ describe("resolveIndexToolkits (logic)", () => { ).toHaveLength(1); expect(links.length).toBe(new Set(links).size); }); + + test("drops catalog entries whose IDs cannot form a route slug", () => { + const invalid = { + ...makeToolkit("---", "development", "invalid"), + id: "---", + docsLink: null, + }; + + expect(resolveIndexToolkits([invalid], new Set())).toEqual([]); + }); }); describe("integrations index links (live data)", () => { diff --git a/tests/sitemap-toolkit-routes.test.ts b/tests/sitemap-toolkit-routes.test.ts new file mode 100644 index 000000000..7ad7decf6 --- /dev/null +++ b/tests/sitemap-toolkit-routes.test.ts @@ -0,0 +1,36 @@ +import { expect, test, vi } from "vitest"; + +const { listToolkitRoutes } = vi.hoisted(() => ({ + listToolkitRoutes: vi.fn(), +})); + +vi.mock("../app/_lib/toolkit-static-params", () => ({ + listToolkitRoutes, +})); + +test("sitemap excludes toolkit routes that redirect to the integrations index", async () => { + const previousSiteUrl = process.env.SITE_URL; + process.env.SITE_URL = "https://example.test"; + listToolkitRoutes.mockResolvedValue([ + { category: "development", toolkitId: "github" }, + { category: "others", toolkitId: "unknown-toolkit" }, + ]); + + try { + const { default: sitemap } = await import("../app/sitemap"); + const urls = (await sitemap()).map((entry) => entry.url); + + expect(urls).toContain( + "https://example.test/en/resources/integrations/development/github" + ); + expect(urls).not.toContain( + "https://example.test/en/resources/integrations/others/unknown-toolkit" + ); + } finally { + if (previousSiteUrl === undefined) { + process.env.SITE_URL = undefined; + } else { + process.env.SITE_URL = previousSiteUrl; + } + } +}); diff --git a/tests/sitemap.test.ts b/tests/sitemap.test.ts index 854fa6234..9b8e0ccea 100644 --- a/tests/sitemap.test.ts +++ b/tests/sitemap.test.ts @@ -22,6 +22,24 @@ test("sitemap lists expected URLs", async () => { // Known page should be present expect(urls).toContain("https://example.test/en/references/changelog"); + const { listToolkitRoutes } = await import( + "../app/_lib/toolkit-static-params" + ); + // Sitemap omits `others` — those paths redirect to the integrations index. + const toolkitUrls = (await listToolkitRoutes()) + .filter(({ category }) => category !== "others") + .map( + ({ category, toolkitId }) => + `https://example.test/en/resources/integrations/${category}/${toolkitId}` + ); + expect(toolkitUrls.length).toBeGreaterThan(0); + for (const toolkitUrl of toolkitUrls) { + expect(urls).toContain(toolkitUrl); + } + expect( + urls.some((url) => url.includes("/resources/integrations/others/")) + ).toBe(false); + // No duplicates const duplicates = urls.filter( (url, index, arr) => arr.indexOf(url) !== index diff --git a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts index 01927bec6..f3a0c6d3f 100644 --- a/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts +++ b/toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts @@ -28,6 +28,8 @@ import { import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { TOOLKITS as DESIGN_SYSTEM_TOOLKITS } from "@arcadeai/design-system/metadata/toolkits"; +import { getToolkitSlug } from "../../app/_lib/toolkit-slug"; +import { normalizeCategory } from "../../app/_lib/toolkit-static-params"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -86,18 +88,9 @@ const CATEGORY_ORDER = [ const CAPITAL_LETTER_REGEX = /([A-Z])/g; const FIRST_CHARACTER_REGEX = /^./; -const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g; const IDENTIFIER_KEY_REGEX = /^[A-Za-z_$][A-Za-z0-9_$]*$/; -/** - * Convert a CamelCase string to kebab-case. - * Must stay in sync with toKebabCase in app/_lib/toolkit-slug.ts. - */ -function toKebabCase(value: string): string { - return value.replace(CAMEL_BOUNDARY, "$1-$2").toLowerCase(); -} - type ToolkitJson = { id?: string; label?: string; @@ -114,8 +107,7 @@ function renderObjectKey(key: string): string { if (IDENTIFIER_KEY_REGEX.test(key)) { return key; } - const escaped = key.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `"${escaped}"`; + return JSON.stringify(key); } export type ToolkitInfo = { @@ -231,21 +223,6 @@ function readToolkitJson(dataDir: string, slug: string): ToolkitJson | null { return null; } -function getDocsSlugFromLink(docsLink?: string | null): string | null { - if (!docsLink) { - return null; - } - - try { - const url = new URL(docsLink); - const segments = url.pathname.split("/").filter(Boolean); - return segments.at(-1) ?? null; - } catch { - const segments = docsLink.split("/").filter(Boolean); - return segments.at(-1) ?? null; - } -} - /** * Read toolkit JSON and extract label if available */ @@ -294,8 +271,13 @@ function resolveToolkitInfo( ): ToolkitInfoEntry | null { const jsonData = readToolkitJson(dataDir, slug); const toolkitId = jsonData?.id ?? slug; - const docsSlug = - getDocsSlugFromLink(jsonData?.metadata?.docsLink) ?? toKebabCase(toolkitId); + const docsSlug = getToolkitSlug({ + id: toolkitId, + docsLink: jsonData?.metadata?.docsLink, + }); + if (!docsSlug) { + return null; + } const designSystemToolkit = TOOLKITS.find( (t) => t.id.toLowerCase() === toolkitId.toLowerCase() ); @@ -306,8 +288,9 @@ function resolveToolkitInfo( // Keep sidebar routes aligned with static params: toolkit JSON is source of // truth for category, with design system as fallback when JSON is missing. - const category = - jsonData?.metadata?.category ?? designSystemToolkit?.category ?? "others"; + const category = normalizeCategory( + jsonData?.metadata?.category ?? designSystemToolkit?.category + ); const labelFromDesignSystem = designSystemToolkit?.label ?? null; const labelFromJson = jsonData?.label ?? jsonData?.name ?? null; const typeFromJson = jsonData?.metadata?.type ?? null; @@ -382,6 +365,7 @@ export function generateCategoryMeta( category: string, integrationsBasePath: string ): string { + const safeCategory = normalizeCategory(category); const byLabel = (a: ToolkitInfo, b: ToolkitInfo) => a.label.localeCompare(b.label); @@ -393,11 +377,10 @@ export function generateCategoryMeta( .sort(byLabel); const renderEntry = (t: ToolkitInfo) => { - // Escape any quotes in the label - const escapedLabel = t.label.replace(/"/g, '\\"'); + const href = `${integrationsBasePath}/${safeCategory}/${t.slug}`; return ` ${renderObjectKey(t.slug)}: { - title: "${escapedLabel}", - href: "${integrationsBasePath}/${category}/${t.slug}", + title: ${JSON.stringify(t.label)}, + href: ${JSON.stringify(href)}, }`; }; @@ -405,7 +388,7 @@ export function generateCategoryMeta( const key = `-- ${title}`; return ` ${renderObjectKey(key)}: { type: "separator", - title: "${title}", + title: ${JSON.stringify(title)}, }`; }; @@ -460,7 +443,7 @@ export function generateMainMeta(activeCategories: string[]): string { .map((cat) => { const displayName = CATEGORY_NAMES[cat] || cat; return ` ${renderObjectKey(cat)}: { - title: "${displayName}", + title: ${JSON.stringify(displayName)}, }`; }) .join(",\n"); diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts index 91a0bdf36..719bf9c84 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-data.test.ts @@ -73,6 +73,38 @@ describe("toolkit data loader", () => { }); }); + it("retains valid toolkit index entries when another entry is malformed", async () => { + await withTempDir(async (dir) => { + await writeFile( + join(dir, "index.json"), + JSON.stringify({ + generatedAt: "2026-01-15T00:00:00.000Z", + version: "1.0.0", + toolkits: [ + { + id: "Github", + label: "GitHub", + version: "1.0.0", + category: "development", + toolCount: 3, + authType: "oauth2", + }, + { category: "development" }, + ], + }), + "utf-8" + ); + + await expect(readToolkitIndex({ dataDir: dir })).resolves.toMatchObject({ + toolkits: [ + { + id: "Github", + }, + ], + }); + }); + }); + it("finds toolkit data by docsLink slug when file name doesn't match", async () => { await withTempDir(async (dir) => { // File is named posthogapi.json (normalized) but we look up by the diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts index 74d7a4017..d62910789 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-slug.test.ts @@ -146,6 +146,24 @@ describe("getToolkitSlug", () => { expect(getToolkitSlug(source)).toBe("gmail"); }); + it("falls back when docsLink contains an unsafe encoded slug", () => { + expect( + getToolkitSlug({ + id: "UnsafeToolkit", + docsLink: "https://docs.arcade.dev/bad%2Fslug", + }) + ).toBe("unsafe-toolkit"); + }); + + it("falls back for prototype-like docsLink slugs", () => { + expect( + getToolkitSlug({ + id: "Github", + docsLink: "https://docs.arcade.dev/__proto__", + }) + ).toBe("github"); + }); + it("converts mixed-case IDs to kebab-case in fallback", () => { const source: ToolkitSlugSource = { id: "GoogleDrive", @@ -170,4 +188,8 @@ describe("getToolkitSlug", () => { }; expect(getToolkitSlug(source)).toBe("slack"); }); + + it("returns null when neither docsLink nor toolkit ID can form a slug", () => { + expect(getToolkitSlug({ id: "---", docsLink: null })).toBeNull(); + }); }); diff --git a/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts b/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts index 8c5c59f2c..6d1e3376b 100644 --- a/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts +++ b/toolkit-docs-generator/tests/app-lib/toolkit-static-params.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { normalizeToolkitId } from "../../../app/_lib/toolkit-slug"; import { + getToolkitCanonicalPath, getToolkitStaticParamsForCategory, listToolkitRoutes, type ToolkitCatalogEntry, @@ -26,7 +27,15 @@ const writeIndex = async ( { generatedAt: "2026-01-15T00:00:00.000Z", version: "1.0.0", - toolkits, + toolkits: toolkits.map(({ id, category }) => ({ + id, + label: id, + version: "1.0.0", + category: category ?? "others", + type: "arcade", + toolCount: 1, + authType: "none", + })), }, null, 2 @@ -57,6 +66,29 @@ describe("toolkit static params", () => { expect(normalizeToolkitId("GitHub API")).toBe("githubapi"); }); + it("rejects canonical paths for toolkit IDs without a valid slug", () => { + expect(() => + getToolkitCanonicalPath({ id: "---", category: "development" }) + ).toThrow("Cannot build a canonical path"); + }); + + it("skips toolkit files whose IDs cannot form a route slug", async () => { + await withTempDir(async (dir) => { + await writeFile( + join(dir, "invalid.json"), + JSON.stringify({ + id: "---", + label: "Invalid", + metadata: { category: "development" }, + }) + ); + + await expect( + listToolkitRoutes({ dataDir: dir, toolkitsCatalog: [] }) + ).resolves.toEqual([]); + }); + }); + it("lists toolkit routes from the index", async () => { await withTempDir(async (dir) => { await writeIndex(dir, [ @@ -78,6 +110,27 @@ describe("toolkit static params", () => { }); }); + it("returns toolkit routes in deterministic category and slug order", async () => { + await withTempDir(async (dir) => { + await writeIndex(dir, [ + { id: "Slack", category: "social" }, + { id: "Gmail", category: "productivity" }, + { id: "Github", category: "development" }, + ]); + + const routes = await listToolkitRoutes({ + dataDir: dir, + toolkitsCatalog: [], + }); + + expect(routes).toEqual([ + { toolkitId: "github", category: "development" }, + { toolkitId: "gmail", category: "productivity" }, + { toolkitId: "slack", category: "social" }, + ]); + }); + }); + it("uses catalog category as fallback when JSON file is absent", async () => { await withTempDir(async (dir) => { await writeIndex(dir, [{ id: "Github", category: "development" }]); diff --git a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts index 273ff1e19..98dbe62b4 100644 --- a/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts +++ b/toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts @@ -270,6 +270,51 @@ describe("buildToolkitInfoList", () => { expect(entry?.category).toBe("sales"); }); + it("maps unknown categories to the safe others directory", () => { + createToolkitJson("unsafe", { + id: "UnsafeToolkit", + label: "Unsafe", + metadata: { + category: "../../outside", + docsLink: "https://docs.arcade.dev/integrations/unsafe", + }, + }); + + const [entry] = buildToolkitInfoList(TEST_DATA_DIR); + + expect(entry?.category).toBe("others"); + }); + + it("falls back when docsLink does not end in a safe slug", () => { + createToolkitJson("unsafe", { + id: "UnsafeToolkit", + label: "Unsafe", + metadata: { + category: "development", + docsLink: "https://docs.arcade.dev/bad%2Fslug", + }, + }); + + const [entry] = buildToolkitInfoList(TEST_DATA_DIR); + + expect(entry?.slug).toBe("unsafe-toolkit"); + }); + + it("uses the shared normalized fallback for legacy toolkit IDs", () => { + createToolkitJson("unsafe", { + id: "Unsafe Toolkit", + label: "Unsafe", + metadata: { + category: "development", + docsLink: "https://docs.arcade.dev/bad%2Fslug", + }, + }); + + const [entry] = buildToolkitInfoList(TEST_DATA_DIR); + + expect(entry?.slug).toBe("unsafetoolkit"); + }); + it("should dedupe entries that share a docsLink slug", () => { createToolkitJson("upclickapi", { id: "UpclickApi", @@ -505,6 +550,22 @@ describe("generateCategoryMeta", () => { expect(result).toContain('title: "Test \\"Quoted\\" Label"'); }); + it("should escape backslashes and newlines in labels", () => { + const toolkits: ToolkitInfo[] = [ + { + id: "test", + slug: "test", + label: "Test\\Label\nNext", + category: "others", + navGroup: "optimized", + }, + ]; + + const result = generateCategoryMeta(toolkits, "others", "/preview"); + + expect(result).toContain('title: "Test\\\\Label\\nNext"'); + }); + it("should handle empty array", () => { const result = generateCategoryMeta([], "productivity", "/preview");