Skip to content

Validate and canonicalize generated toolkit routes#1065

Draft
jottakka wants to merge 9 commits into
mainfrom
split/validate-toolkit-doc-routes
Draft

Validate and canonicalize generated toolkit routes#1065
jottakka wants to merge 9 commits into
mainfrom
split/validate-toolkit-doc-routes

Conversation

@jottakka

@jottakka jottakka commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • validate every generated toolkit slug before it becomes a route
  • make integration cards, static parameters, and the sitemap share that contract
  • sort and deduplicate generated routes deterministically

Test plan

  • pnpm test

Note

Medium Risk
Touches integration routing, index link resolution, and sitemap/canonical SEO across many entry points; behavior is defensive and heavily tested but mis-slugging could hide or mis-link catalog entries.

Overview
Unifies how integration categories, slugs, and card URLs are derived so the catalog, static routes, sidebar, sitemap, and canonical metadata stay in sync.

Slug safety: getToolkitSlug now rejects unsafe path segments and returns null when no valid slug exists; route listing, index resolution, canonical paths, and llms.txt skip those entries instead of emitting bad URLs.

Shared contract: normalizeCategory and the category allow-list move to a client-safe toolkit-category module; catalog enrichment prefers toolkit JSON over design-system metadata for docsLink and category (matching route helpers).

Integrations index: resolveIndexToolkits exposes a final link (including lone bare-name → -api remaps when no sibling owns the page), drops unsluggable catalog rows, and cards use that link rather than recomputing on the client.

Data loading: Toolkit JSON and index.json are validated with Zod; malformed index rows are filtered without dropping the whole index.

SEO / discovery: The sitemap merges authored MDX URLs with listValidIntegrationLinks, dedupes by URL, and omits others integration paths that redirect; toolkit pages only set alternates.canonical when a canonical path exists.

Generator: Sidebar sync reuses shared slug/category helpers and safer JSON escaping for generated _meta.tsx files.

Reviewed by Cursor Bugbot for commit bac2dce. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jul 21, 2026 5:13pm

Request Review

Comment thread app/_lib/toolkit-data.ts Outdated
Comment thread app/sitemap.ts Outdated
@jottakka
jottakka marked this pull request as draft July 16, 2026 18:22
Use one safe slug contract across route discovery, integration cards, and the sitemap so invalid or duplicate entries cannot publish broken links.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep a malformed index entry from changing the source of the entire route set, and prevent redirecting fallback-category URLs from entering the sitemap.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jottakka jottakka self-assigned this Jul 18, 2026
@jottakka
jottakka marked this pull request as ready for review July 18, 2026 21:37
Comment thread tests/sitemap.test.ts Outdated
Comment thread app/_lib/toolkit-data.ts Outdated
Comment on lines +72 to +106
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;

const isValidToolkitData = (parsed: unknown): parsed is ToolkitData =>
typeof parsed === "object" &&
parsed !== null &&
isRecord(parsed) &&
"id" in parsed &&
("label" in parsed || "name" in parsed) &&
"metadata" in parsed &&
typeof (parsed as Record<string, unknown>).metadata === "object" &&
(parsed as Record<string, unknown>).metadata !== null;
isRecord(parsed.metadata);

const isToolkitIndexEntry = (value: unknown): value is ToolkitIndexEntry => {
if (!isRecord(value)) {
return false;
}

return (
typeof value.id === "string" &&
typeof value.label === "string" &&
typeof value.version === "string" &&
typeof value.category === "string" &&
(value.type === undefined || typeof value.type === "string") &&
typeof value.toolCount === "number" &&
Number.isInteger(value.toolCount) &&
value.toolCount >= 0 &&
typeof value.authType === "string"
);
};

const isToolkitIndexEnvelope = (
value: unknown
): value is ToolkitIndexEnvelope =>
isRecord(value) &&
typeof value.generatedAt === "string" &&
typeof value.version === "string" &&
Array.isArray(value.toolkits);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @teallarson would these be better off as zod schemas?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100%

The generator already has a schema... we can't import that here because of module resolution BUT we can define one in app/_lib/toolkit-data.ts.

Something like:

// isToolkitIndexEntry becomes
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(),
});

...
// isToolkitIndexEnvelope becomes
const ToolkitIndexEnvelopeSchema = z.object({
  generatedAt: z.string(),
  version: z.string(),
  toolkits: z.array(z.unknown()),
});

...
// isValidToolkitData becomes: 

const ToolkitDataSchema = z
  .object({
    id: z.string(),
    label: z.string().optional(),
    name: z.string().optional(),
    metadata: z.object({}).passthrough(),
  })
  .passthrough()
  .refine((v) => v.label !== undefined || v.name !== undefined);
  
  //etc.

cc: @jottakka !

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the app-local Zod schemas as sketched (envelope + per-entry filter). See also the later note about how defensive this is — open to simplifying if you’d prefer slug-skip only.

fix: keep generated sidebar links aligned with toolkit routes

Reuse the canonical toolkit slug and category validation when rendering sidebar metadata, including safely escaped labels and keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread toolkit-docs-generator/scripts/sync-toolkit-sidebar.ts Outdated
Use app-local Zod schemas for toolkit data/index parsing, align the
sitemap test with the others-category exclusion, and reuse the shared
normalizeCategory helper in sidebar sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread app/sitemap.ts
@jottakka

Copy link
Copy Markdown
Contributor Author

Addressed the Zod / sitemap / shared-normalizeCategory review feedback in the latest commits.

Remaining card↔route category contract gaps (raw toIntegrationLink category, catalog not backfilling JSON category/docsLink precedence) are intentionally in the independent follow-up: #1088

Re-requesting review for the Zod change on this PR.

@teallarson teallarson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is doing the right thing overall. Making sure toolkit page URLs are built the same way everywhere (cards, sitemap, sidebar, etc.).

The main feedback is: let's please clean up some duplicated logic and tests in here 🙏

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a toolkit can’t produce a valid URL, this code throws an error in one place and catches it in another. That works, but it’s harder to follow than needed. Simpler approach: if we can’t build a URL, just skip that toolkit and move on — no throw/catch needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also: Sidebar and canonical URLs now sanitize categories (so bad values like ../../outside get mapped to others), but integration card links don’t seem to do that yet. Should those use the same category cleanup so cards and pages always match?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — toIntegrationLink now returns null for bad slugs, and resolveIndexToolkits just skips those entries (no throw/catch).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — cards now use the same client-safe normalizeCategory helper as routes/sidebar (app/_lib/toolkit-category.ts), so unknown/empty categories map to others consistently.

Comment on lines +80 to +82
if (!slug) {
throw new Error(`Cannot build a canonical path for toolkit: ${toolkit.id}`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to the comment above: invalid slugs throw errors that get caught elsewhere. Would be cleaner if “bad slug” just meant “don’t include this page” everywhere, consistently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — getToolkitCanonicalPath now returns null instead of throwing; callers skip / omit canonical when there is no safe slug.

Comment thread app/_lib/toolkit-data.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a fair amount of validation code. Is that solving a real problem we’ve hit (bad rows in index.json), or is it defensive?

If we haven’t seen bad data in practice, we might get most of the benefit from just skipping toolkits with invalid slugs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly defensive + consistency with the earlier request to use Zod here (generator already validates with Zod; we can’t import those schemas into app/_lib).

Practical benefit we kept: envelope validates, then filters bad index rows instead of dropping the whole catalog. Happy to slim this further to “skip invalid slugs only” if you’d rather — the slug skip path is now consistent everywhere either way.

Comment thread tests/sitemap-toolkit-routes.test.ts
Comment thread toolkit-docs-generator/tests/scripts/sync-toolkit-sidebar.test.ts
Comment thread app/sitemap.ts
jottakka and others added 3 commits July 21, 2026 12:41
Extract a client-safe toolkit-category helper so integration index links
and catalog enrichment use the same category allow-list as static routes,
instead of raw catalog categories that can miss pages or 404.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep catalog enrichment aligned with route generation: toolkit JSON wins
for docsLink, and category values are normalized before cards/filters use them.

Co-authored-by: Cursor <cursoragent@cursor.com>
Return null instead of throw/catch for invalid integration/canonical URLs,
reuse listValidIntegrationLinks in the sitemap (excluding others), fold in
client-safe category normalization for cards, and trim overlapping sitemap
and sidebar tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jottakka

Copy link
Copy Markdown
Contributor Author

Addressed Teal’s latest review comments in the newest commits:

  • no throw/catch for bad URLs — return null and skip
  • cards use shared normalizeCategory (folded Normalize integration card categories to match routes #1088 into this PR)
  • sitemap reuses listValidIntegrationLinks (excludes others)
  • deduped sitemap + trimmed overlapping sidebar slug tests
  • left a note on the Zod thread about defensive vs slug-skip-only

#1088 can be closed as superseded.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit baee0a0. Configure here.

Comment thread app/_lib/integration-index.ts
...toolkit,
category,
...(docsLink ? { docsLink } : {}),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty docsLink fails to override

Medium Severity

Explicit empty docsLink values from toolkit JSON are not correctly applied in getToolkitsWithDocsLinks. The docsLink ? { docsLink } : {} spread condition treats empty strings as falsy, allowing existing design-system docsLinks to persist. This can cause card slugs to diverge from generated routes, which correctly interpret an empty docsLink as a fallback to the ID slug.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit baee0a0. Configure here.

normalizeCategory can return docs-only "others", which is not in the
design-system ToolkitCategory union — widen the docs toolkit type and
filter helper so the Next build typecheck passes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Only drop a bare name when another catalog toolkit owns the -api URL;
otherwise remap the card onto that page and store the resolved link on
the index entry so the client does not recompute a dead bare href.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants