feat(marketing): add SEO comparison pages#1160
Conversation
📝 WalkthroughWalkthroughAdds a comparison-pages feature to the marketing site: four new MDX comparison articles, content model and parsing utilities, a dynamic Next.js compare route with metadata and JSON‑LD, a CompareLayout component, table styling, sitemap inclusion, and remark-gfm support for MDX tables. Changes
Sequence DiagramsequenceDiagram
participant User
participant NextRouter as Next.js Router
participant CompareLib as compare.ts
participant MDXParser as MDX (+remark-gfm)
participant Components as React Components
participant Browser
User->>NextRouter: GET /compare/:slug
NextRouter->>CompareLib: getComparisonPage(slug)
CompareLib-->>NextRouter: ComparisonPage { frontmatter, content, url }
NextRouter->>MDXParser: render MDX content (remark-gfm)
MDXParser-->>NextRouter: React nodes
NextRouter->>Components: render ComparisonJsonLd + CompareLayout + mdxComponents
Components-->>Browser: HTML + JSON‑LD + styled content
Browser->>Browser: display page with SEO metadata
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@apps/marketing/docs/SEO_COMPARISON_PAGES_PLAN.md`:
- Around line 218-268: The markdownlint MD040 warning is triggered by unlabeled
fenced code blocks in SEO_COMPARISON_PAGES_PLAN.md; fix it by adding a language
identifier (e.g., text) to the opening fences of each template/code-tree block —
replace the three opening "```" fences that precede "1. Hero:..." (1v1
template), "1. Hero: 'Best [Category]...'" (Category Roundup template), and the
"apps/marketing/" directory tree block with "```text" so each fenced block
begins with ```text and keeps the existing closing ``` fences unchanged.
In `@apps/marketing/src/lib/compare.ts`:
- Around line 47-48: The catch block currently swallows frontmatter parse
errors; update the catch to capture the error (e.g., catch (err)) and log it
using the prefixed pattern (for example
console.error(`[compare/parseFrontmatter] Failed to parse frontmatter for
${filePath || id || source}:`, err)) so errors are visible, then continue to
return null if that’s the intended fallback; modify the catch around the
frontmatter parsing code in compare.ts accordingly.
- Around line 70-74: The getComparisonPage function builds filePath from slug
without validation, allowing path traversal; validate the slug before using it
(e.g., enforce an allowlist regex like only alphanumeric, dashes/underscores, no
path separators or '..') or sanitize by resolving the path (use
path.resolve(path.join(COMPARE_DIR, `${slug}.mdx`)) and then assert the resolved
path startsWith the resolved COMPARE_DIR) and return undefined (or throw) if
validation fails; update getComparisonPage to perform this check before calling
fs.existsSync and reading the file.
🧹 Nitpick comments (7)
apps/marketing/content/compare/superset-vs-opencode.mdx (1)
23-31: Consider time‑stamping volatile metrics.
Line 26–31 includes pricing and GitHub star counts that can drift; adding “as of Feb 2, 2026” or avoiding exact figures can reduce staleness.apps/marketing/src/lib/compare-utils.ts (1)
6-17: Document why optional fields can be absent.
lastUpdated?andimage?are optional but lack justification; a short JSDoc keeps intent clear and aligns with the interface guideline.Suggested update
export interface ComparisonPage { slug: string; url: string; title: string; description: string; date: string; - lastUpdated?: string; + /** ISO date of the most recent substantive update; omitted if never updated. */ + lastUpdated?: string; type: "1v1" | "roundup" | "tutorial"; competitors: string[]; keywords: string[]; - image?: string; + /** Optional OG/social image URL when available. */ + image?: string; content: string; }As per coding guidelines: Make dependencies required in interfaces unless truly optional; document why if optional.
apps/marketing/src/lib/compare.ts (1)
13-15: Type frontmatter to avoid implicitany.
gray-matterreturnsdata: any; add a typed frontmatter interface and use the generic to keep type safety.Suggested update
import { slugify, type TocItem } from "./blog-utils"; import type { ComparisonPage } from "./compare-utils"; export { type ComparisonPage, formatCompareDate } from "./compare-utils"; const COMPARE_DIR = path.join(process.cwd(), "content/compare"); +interface CompareFrontmatter { + title?: string; + description?: string; + date?: string | Date; + lastUpdated?: string | Date; + type?: ComparisonPage["type"]; + competitors?: string[]; + keywords?: string[]; + image?: string; +} + function parseFrontmatter(filePath: string): ComparisonPage | null { try { const fileContent = fs.readFileSync(filePath, "utf-8"); - const { data, content } = matter(fileContent); + const { data, content } = matter<CompareFrontmatter>(fileContent);As per coding guidelines: Avoid
anytype unless necessary.apps/marketing/content/compare/superset-vs-cursor.mdx (1)
22-29: Consider time‑stamping model/pricing details.
Line 25–29 lists specific models and pricing tiers that can change; adding “as of Feb 2, 2026” or softening the specificity can reduce future drift.apps/marketing/src/app/sitemap.ts (1)
61-67: Extract the comparison priority into a named constant.Line 66 introduces
0.7as a magic number; pulling it to a module-level constant improves clarity and reuse.As per coding guidelines: Avoid magic numbers by extracting them to named constants at module top.
♻️ Proposed change
+const COMPARISON_PAGE_PRIORITY = 0.7; + export default function sitemap(): MetadataRoute.Sitemap { const baseUrl = COMPANY.MARKETING_URL; @@ const comparisonPages: MetadataRoute.Sitemap = getComparisonPages().map( (page) => ({ url: `${baseUrl}/compare/${page.slug}`, lastModified: new Date(page.lastUpdated || page.date), changeFrequency: "monthly" as const, - priority: 0.7, + priority: COMPARISON_PAGE_PRIORITY, }), );apps/marketing/src/app/compare/[slug]/components/CompareLayout/CompareLayout.tsx (1)
20-24: Extract grid offset magic numbers to constants.Lines 22-24 hardcode
384/383in the gradient string; using named constants makes the layout intent clearer and easier to tweak.As per coding guidelines: Avoid magic numbers by extracting them to named constants at module top.
♻️ Proposed change
+const GRID_LINE_OFFSET = 384; +const GRID_LINE_OFFSET_INNER = GRID_LINE_OFFSET - 1; +const GRID_BACKGROUND = ` + linear-gradient( + to right, + transparent 0%, + transparent calc(50% - ${GRID_LINE_OFFSET}px), + rgba(255,255,255,0.06) calc(50% - ${GRID_LINE_OFFSET}px), + rgba(255,255,255,0.06) calc(50% - ${GRID_LINE_OFFSET_INNER}px), + transparent calc(50% - ${GRID_LINE_OFFSET_INNER}px), + transparent calc(50% + ${GRID_LINE_OFFSET_INNER}px), + rgba(255,255,255,0.06) calc(50% + ${GRID_LINE_OFFSET_INNER}px), + rgba(255,255,255,0.06) calc(50% + ${GRID_LINE_OFFSET}px), + transparent calc(50% + ${GRID_LINE_OFFSET}px) + ) +`; + export function CompareLayout({ page, children }: CompareLayoutProps) { const formattedDate = formatCompareDate(page.lastUpdated ?? page.date); @@ <div className="absolute inset-0 pointer-events-none" style={{ - backgroundImage: ` - linear-gradient(to right, transparent 0%, transparent calc(50% - 384px), rgba(255,255,255,0.06) calc(50% - 384px), rgba(255,255,255,0.06) calc(50% - 383px), transparent calc(50% - 383px), transparent calc(50% + 383px), rgba(255,255,255,0.06) calc(50% + 383px), rgba(255,255,255,0.06) calc(50% + 384px), transparent calc(50% + 384px)) - `, + backgroundImage: GRID_BACKGROUND, }} />apps/marketing/src/components/JsonLd/JsonLd.tsx (1)
105-112: Document why optional JSON-LD props are optional.
modifiedTimeandimageare optional but not documented. A short rationale keeps the interface self-explanatory.As per coding guidelines: Make dependencies required in interfaces unless truly optional; document why if optional.
♻️ Proposed change
interface ComparisonJsonLdProps { title: string; description: string; publishedTime: string; - modifiedTime?: string; + /** Optional; falls back to publishedTime when not provided. */ + modifiedTime?: string; url: string; - image?: string; + /** Optional; include only when a canonical image exists. */ + image?: string; }
| ### 1v1 Comparison Template | ||
| ``` | ||
| 1. Hero: "[Tool A] vs [Tool B]" + one-sentence summary | ||
| 2. TL;DR comparison table (5-7 key dimensions) | ||
| 3. What is [Tool A]? (1-2 paragraphs) | ||
| 4. What is [Tool B]? (1-2 paragraphs) | ||
| 5. Detailed comparison sections (3-5 sections): | ||
| - Agent approach / AI capabilities | ||
| - Developer experience & workflow | ||
| - Pricing & plans | ||
| - Privacy & security | ||
| - Best for (use cases) | ||
| 6. When to use [Tool A] vs [Tool B] (decision framework) | ||
| 7. Can you use both? (complementary angle) | ||
| 8. Verdict / Recommendation | ||
| 9. FAQ section (3-5 questions — good for featured snippets) | ||
| 10. CTA: Try Superset | ||
| ``` | ||
|
|
||
| ### Category Roundup Template | ||
| ``` | ||
| 1. Hero: "Best [Category] in 2026" + one-sentence summary | ||
| 2. Quick comparison table (all tools, 5-7 dimensions) | ||
| 3. Tool-by-tool breakdown (each with pros/cons/pricing/best for) | ||
| 4. How to choose: decision framework | ||
| 5. The orchestration approach (positions Superset) | ||
| 6. FAQ section | ||
| 7. CTA: Try Superset | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Implementation Plan | ||
|
|
||
| ### Route Structure | ||
|
|
||
| Pages live under `/compare/[slug]` in the marketing app: | ||
|
|
||
| ``` | ||
| apps/marketing/ | ||
| ├── content/ | ||
| │ └── compare/ # NEW: MDX content files | ||
| │ ├── superset-vs-warp.mdx | ||
| │ ├── superset-vs-cursor.mdx | ||
| │ ├── best-ai-coding-agent-tools.mdx | ||
| │ ├── superset-vs-devin.mdx | ||
| │ ├── multiple-claude-code-agents-parallel.mdx | ||
| │ ├── superset-vs-github-copilot.mdx | ||
| │ ├── best-terminal-for-ai-coding.mdx | ||
| │ ├── superset-vs-conductor.mdx | ||
| │ └── superset-vs-openai-codex.mdx |
There was a problem hiding this comment.
Add language identifiers to fenced code blocks.
markdownlint MD040 flags the template and tree fences—add a language hint (e.g., text) to silence the warning.
✍️ Proposed change
-```
+```text
1. Hero: "[Tool A] vs [Tool B]" + one-sentence summary
@@
-```
+```
-```
+```text
1. Hero: "Best [Category] in 2026" + one-sentence summary
@@
-```
+```
-```
+```text
apps/marketing/
├── content/
@@
-```
+```🧰 Tools
🪛 markdownlint-cli2 (0.20.0)
[warning] 219-219: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
[warning] 238-238: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
[warning] 256-256: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
In `@apps/marketing/docs/SEO_COMPARISON_PAGES_PLAN.md` around lines 218 - 268, The
markdownlint MD040 warning is triggered by unlabeled fenced code blocks in
SEO_COMPARISON_PAGES_PLAN.md; fix it by adding a language identifier (e.g.,
text) to the opening fences of each template/code-tree block — replace the three
opening "```" fences that precede "1. Hero:..." (1v1 template), "1. Hero: 'Best
[Category]...'" (Category Roundup template), and the "apps/marketing/" directory
tree block with "```text" so each fenced block begins with ```text and keeps the
existing closing ``` fences unchanged.
| } catch { | ||
| return null; |
There was a problem hiding this comment.
Don’t swallow frontmatter parse errors.
The catch block returns null without context, which hides content issues during builds and runtime.
Suggested update
- } catch {
- return null;
- }
+ } catch (error) {
+ console.error("[compare/parseFrontmatter] Failed to parse comparison frontmatter", {
+ filePath,
+ error,
+ });
+ return null;
+ }As per coding guidelines: Never swallow errors silently; at minimum log them with context; Use prefixed console logging with pattern [domain/operation] message for all logging.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch { | |
| return null; | |
| } catch (error) { | |
| console.error("[compare/parseFrontmatter] Failed to parse comparison frontmatter", { | |
| filePath, | |
| error, | |
| }); | |
| return null; | |
| } |
🤖 Prompt for AI Agents
In `@apps/marketing/src/lib/compare.ts` around lines 47 - 48, The catch block
currently swallows frontmatter parse errors; update the catch to capture the
error (e.g., catch (err)) and log it using the prefixed pattern (for example
console.error(`[compare/parseFrontmatter] Failed to parse frontmatter for
${filePath || id || source}:`, err)) so errors are visible, then continue to
return null if that’s the intended fallback; modify the catch around the
frontmatter parsing code in compare.ts accordingly.
| export function getComparisonPage(slug: string): ComparisonPage | undefined { | ||
| const filePath = path.join(COMPARE_DIR, `${slug}.mdx`); | ||
|
|
||
| if (!fs.existsSync(filePath)) { | ||
| return undefined; |
There was a problem hiding this comment.
Validate slug before building the file path.
slug is used directly in path.join; if it contains path separators or .., it can read outside content/compare. Add a strict allowlist or resolved-path check.
Suggested update
const COMPARE_DIR = path.join(process.cwd(), "content/compare");
+const SAFE_SLUG = /^[a-z0-9-]+$/i;
export function getComparisonPage(slug: string): ComparisonPage | undefined {
+ if (!SAFE_SLUG.test(slug)) {
+ return undefined;
+ }
const filePath = path.join(COMPARE_DIR, `${slug}.mdx`);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function getComparisonPage(slug: string): ComparisonPage | undefined { | |
| const filePath = path.join(COMPARE_DIR, `${slug}.mdx`); | |
| if (!fs.existsSync(filePath)) { | |
| return undefined; | |
| const SAFE_SLUG = /^[a-z0-9-]+$/i; | |
| export function getComparisonPage(slug: string): ComparisonPage | undefined { | |
| if (!SAFE_SLUG.test(slug)) { | |
| return undefined; | |
| } | |
| const filePath = path.join(COMPARE_DIR, `${slug}.mdx`); | |
| if (!fs.existsSync(filePath)) { | |
| return undefined; |
🤖 Prompt for AI Agents
In `@apps/marketing/src/lib/compare.ts` around lines 70 - 74, The
getComparisonPage function builds filePath from slug without validation,
allowing path traversal; validate the slug before using it (e.g., enforce an
allowlist regex like only alphanumeric, dashes/underscores, no path separators
or '..') or sanitize by resolving the path (use
path.resolve(path.join(COMPARE_DIR, `${slug}.mdx`)) and then assert the resolved
path startsWith the resolved COMPARE_DIR) and return undefined (or throw) if
validation fails; update getComparisonPage to perform this check before calling
fs.existsSync and reading the file.
…ctor, and Warp - Add /compare/[slug] dynamic route with MDX rendering, JSON-LD, and sitemap integration - Add remark-gfm for markdown table support in MDX - Add prose table styles to globals.css - Trim comparison content to ~800 words each with accurate pricing ($20/seat/mo Pro tier)
58b49c2 to
aa2a5b7
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@apps/marketing/content/compare/superset-vs-cursor.mdx`:
- Line 25: Update the table row that currently lists "Claude 4.5 Sonnet" and
"GPT-5" under the Cursor column: replace "Claude 4.5 Sonnet" with the confirmed
name "Claude 4 Sonnet", remove or replace the unverified "GPT-5" entry (either
list the confirmed "gpt-4.1" / "standard models" or a note like "model
availability may vary"), and keep "Gemini 2.5 Pro" as-is; edit the table cell in
the line containing "Agent-agnostic — works with Claude Code..." so the Cursor
models reflect only confirmed names.
In `@apps/marketing/docs/SEO_COMPARISON_PAGES_PLAN.md`:
- Line 151: Update the target keywords line so the GitHub brand is capitalized
correctly: change the keyword string "github copilot alternative" to "GitHub
copilot alternative" (and scan the document for other occurrences of "github" in
the target keywords or prose to replace with "GitHub" for consistent
capitalization).
| | | **Superset** | **Cursor** | | ||
| |---|---|---| | ||
| | **Category** | Agent orchestration terminal | AI code editor (VS Code fork) | | ||
| | **AI approach** | Agent-agnostic — works with Claude Code, Codex, Aider, or any CLI agent | Built-in models (GPT-5, Claude 4.5 Sonnet, Gemini 2.5 Pro) proxied through Cursor servers | |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n apps/marketing/content/compare/superset-vs-cursor.mdx | head -50Repository: superset-sh/superset
Length of output: 2975
🌐 Web query:
Cursor IDE 2026 supported AI models GPT-5 Claude Gemini
💡 Result:
Cursor IDE (Feb 2026): support for GPT / Claude / Gemini
- Claude (Anthropic): Supported. Cursor’s model guides explicitly recommend and reference Claude 4 Sonnet and Claude 4 Opus. [1] Cursor also states you can use all Claude models available via the Anthropic API when you add your own API key. [2]
- Gemini (Google): Supported. Cursor’s model guides explicitly reference Gemini 2.5 Pro (and note Max Mode relevance for Gemini 2.5 Flash/Pro). [1][3] Cursor also supports Gemini models via the Google AI API when you add your own API key. [2]
- GPT-5 (OpenAI): Not confirmed in Cursor’s official docs (from the sources above). Cursor’s docs do confirm OpenAI model support generally (e.g., gpt-4.1 is referenced), but for custom OpenAI API keys Cursor notes it supports “standard, non-reasoning chat models only” and that the model picker will show which OpenAI models are available—so GPT-5 availability depends on what Cursor exposes in-app and whether GPT-5 qualifies under that constraint. [1][2]
Practical check: In Cursor, open Cursor Settings → Models (or the model dropdown) and see whether GPT-5 appears; that’s the definitive answer for your installation/account. [2]
Sources:
[1] Cursor docs – Selecting models (mentions claude-4-sonnet, claude-4-opus, gemini-2.5-pro, gpt-4.1) (docs.cursor.com)
[2] Cursor docs – API Keys (supported providers; OpenAI “standard, non-reasoning chat models only”; Anthropic/Google support) (docs.cursor.com)
[3] Cursor docs – Models (Max Mode relevance for Gemini 2.5 Flash/Pro; frontier models note) (docs.cursor.com)
Citations:
- 1: https://docs.cursor.com/en/guides/selecting-models?utm_source=openai
- 2: https://docs.cursor.com/advanced/api-keys?utm_source=openai
- 3: https://docs.cursor.com/models/?utm_source=openai
Correct model names in the Cursor comparison.
Line 25 contains inaccurate model names. Per Cursor's official documentation:
- "Claude 4.5 Sonnet" should be "Claude 4 Sonnet" (official docs reference Claude 4 Sonnet and Claude 4 Opus, not 4.5)
- "GPT-5" is not confirmed in Cursor's official documentation (docs reference gpt-4.1 and standard models only; GPT-5 availability is unverified)
- "Gemini 2.5 Pro" is correct ✓
Update the table entry to reflect only confirmed models available through Cursor servers, or note that model availability may vary.
🤖 Prompt for AI Agents
In `@apps/marketing/content/compare/superset-vs-cursor.mdx` at line 25, Update the
table row that currently lists "Claude 4.5 Sonnet" and "GPT-5" under the Cursor
column: replace "Claude 4.5 Sonnet" with the confirmed name "Claude 4 Sonnet",
remove or replace the unverified "GPT-5" entry (either list the confirmed
"gpt-4.1" / "standard models" or a note like "model availability may vary"), and
keep "Gemini 2.5 Pro" as-is; edit the table cell in the line containing
"Agent-agnostic — works with Claude Code..." so the Cursor models reflect only
confirmed names.
| - **Why**: This targets Claude Code's active user base directly. High purchase intent — these users already use coding agents and want to do more. | ||
|
|
||
| #### Page 6: Superset vs GitHub Copilot | ||
| - **Target keywords**: "superset vs copilot", "github copilot alternative", "copilot alternative for coding agents" |
There was a problem hiding this comment.
Capitalize "GitHub" consistently.
The target keyword uses lowercase "github" but should be "GitHub" for brand consistency in the prose.
-- **Target keywords**: "superset vs copilot", "github copilot alternative", "copilot alternative for coding agents"
+- **Target keywords**: "superset vs copilot", "GitHub Copilot alternative", "copilot alternative for coding agents"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| - **Target keywords**: "superset vs copilot", "github copilot alternative", "copilot alternative for coding agents" | |
| - **Target keywords**: "superset vs copilot", "GitHub Copilot alternative", "copilot alternative for coding agents" |
🧰 Tools
🪛 LanguageTool
[uncategorized] ~151-~151: The official name of this software platform is spelled with a capital “H”.
Context: ...get keywords**: "superset vs copilot", "github copilot alternative", "copilot alternat...
(GITHUB)
🤖 Prompt for AI Agents
In `@apps/marketing/docs/SEO_COMPARISON_PAGES_PLAN.md` at line 151, Update the
target keywords line so the GitHub brand is capitalized correctly: change the
keyword string "github copilot alternative" to "GitHub copilot alternative" (and
scan the document for other occurrences of "github" in the target keywords or
prose to replace with "GitHub" for consistent capitalization).
🧹 Preview Cleanup CompleteThe following preview resources have been cleaned up:
Thank you for your contribution! 🎉 |
Summary
/compare/[slug]dynamic route infrastructure (content reader, layout, JSON-LD, sitemap integration)remark-gfmfor markdown table rendering and prose table stylesNew files
apps/marketing/src/lib/compare.ts/compare-utils.ts— content reader + typesapps/marketing/src/app/compare/[slug]/page.tsx— dynamic routeapps/marketing/src/app/compare/[slug]/components/CompareLayout/— page layoutapps/marketing/content/compare/*.mdx— 4 comparison pagesModified files
apps/marketing/src/components/JsonLd/JsonLd.tsx— addedComparisonJsonLdapps/marketing/src/app/sitemap.ts— includes comparison pagesapps/marketing/src/app/globals.css— prose table stylesTest plan
bun run lint:fix— no issuesbun run typecheck— 16/16 passbun test— 1193 pass, 0 fail/compare/superset-vs-cursorrenders correctly in devSummary by CodeRabbit
New Features
Documentation
Style
Chores