diff --git a/cloud/app/lib/content/route-config.tsx b/cloud/app/lib/content/route-config.tsx index f3b322e6b0..c9917fd991 100644 --- a/cloud/app/lib/content/route-config.tsx +++ b/cloud/app/lib/content/route-config.tsx @@ -1,8 +1,51 @@ import type React from "react"; import { redirect, useLoaderData } from "@tanstack/react-router"; -import type { Content, ContentMeta } from "@/app/lib/content/types"; +import type { ErrorComponentProps } from "@tanstack/react-router"; +import type { BlogMeta, Content, ContentMeta } from "@/app/lib/content/types"; import { NotFound } from "@/app/components/not-found"; +import { DefaultCatchBoundary } from "@/app/components/error/default-catch-boundary"; +import LoadingContent from "@/app/components/blocks/loading-content"; import type { ModuleMap } from "./virtual-module"; +import { + createPageHead, + canonicalizePath, + routeToImagePath, + generateOpenGraphMeta, + generateTwitterMeta, + generateArticleMeta, + generateArticleJsonLd, + type HeadMetaEntry, + type HeadLinkEntry, + type HeadScriptEntry, + type HeadResult, +} from "@/app/lib/seo/head"; +import { BASE_URL } from "@/app/lib/site"; + +// Re-export types for consumers +export type { HeadMetaEntry, HeadLinkEntry, HeadScriptEntry, HeadResult }; + +// Re-export createPageHead for standalone usage +export { createPageHead }; + +/* ========== CONTENT ROUTE CONFIG =========== */ + +/** + * Return type of createContentRouteConfig. + * Defines the shape expected by TanStack Router's createFileRoute. + */ +export interface ContentRouteConfig { + ssr: false; + head: (ctx: { + match: { pathname: string }; + loaderData?: Content | undefined; + }) => HeadResult; + loader: (context: { + params: Record; + }) => Promise | undefined>; + component: () => React.JSX.Element; + pendingComponent: () => React.JSX.Element; + errorComponent: (props: ErrorComponentProps) => React.JSX.Element; +} /* ========== CONTENT ROUTE OPTIONS =========== */ @@ -24,12 +67,19 @@ export interface ContentRouteOptions { fixedPath?: string; /** The page component to render when content is loaded */ component: React.ComponentType<{ content: Content }>; - /** Title to show while loading (defaults to "Loading...") */ - loadingTitle?: string; /** Redirect configuration for empty splat routes */ redirectOnEmptySplat?: { to: string; params: Record }; /** Custom module map for testing */ _testModuleMap?: ModuleMap; + + /* ========== SEO OPTIONS =========== */ + + /** Content type for Open Graph (defaults to "website") */ + ogType?: "website" | "article"; + /** Robots directive (e.g., "noindex, nofollow") */ + robots?: string; + /** Function to generate social card image path from meta */ + getImagePath?: (meta: TMeta) => string; } /* ========== CONTENT ROUTE FACTORY =========== */ @@ -57,7 +107,8 @@ export interface ContentRouteOptions { export function createContentRouteConfig( path: string, options: ContentRouteOptions, -) { +): ContentRouteConfig { + const allMetas = options.getMeta(); const moduleMap = options._testModuleMap ?? options.moduleMap; // Create the component that will render the content @@ -66,23 +117,12 @@ export function createContentRouteConfig( return { ssr: false as const, - head: (ctx: { loaderData?: Content | undefined }) => { - const meta = ctx.loaderData?.meta; - if (!meta) { - return { - meta: [ - { title: options.loadingTitle ?? "Loading..." }, - { name: "description", content: "Loading content" }, - ], - }; - } - return { - meta: [ - { title: `${meta.title} | Mirascope` }, - { name: "description", content: meta.description }, - ], - }; - }, + head: createContentHead({ + allMetas, + ogType: options.ogType, + robots: options.robots, + getImagePath: options.getImagePath, + }), loader: async (context: { params: Record; @@ -104,10 +144,9 @@ export function createContentRouteConfig( const metaPath = buildMetaPath(context.params, options); // Find metadata (with universal /index fallback) - const metas = options.getMeta(); - let meta = metas.find((m) => m.path === metaPath); + let meta = allMetas.find((m) => m.path === metaPath); if (!meta) { - meta = metas.find((m) => m.path === `${metaPath}/index`); + meta = allMetas.find((m) => m.path === `${metaPath}/index`); } if (!meta) { @@ -133,11 +172,146 @@ export function createContentRouteConfig( }, component: contentComponent, + // todo(sebastian): add the pending component + pendingComponent: () => , + // todo(sebastian): add the error component + errorComponent: DefaultCatchBoundary, }; } /* ========== INTERNAL HELPERS =========== */ +/** + * Type guard to check if metadata is BlogMeta (has article-specific fields). + */ +function isBlogMeta(meta: ContentMeta): meta is BlogMeta { + return meta.type === "blog" && "author" in meta && "date" in meta; +} + +/** + * SEO options for createContentHead function. + */ +interface CreateContentHeadOptions { + allMetas: TMeta[]; + ogType?: "website" | "article"; + robots?: string; + getImagePath?: (meta: TMeta) => string; +} + +/** + * Create a head function for content routes that looks up metadata by route. + * + * This is a specialized wrapper around createPageHead that: + * - Looks up content metadata by route path + * - Auto-detects article type for blog posts + * - Generates article JSON-LD for blog content + */ +function createContentHead( + options: CreateContentHeadOptions, +) { + const { allMetas, ogType = "website", robots, getImagePath } = options; + + return (ctx: { + match: { pathname: string }; + loaderData?: Content | undefined; + }): HeadResult => { + const route = ctx.match.pathname; + const meta = allMetas.find((m) => m.route === route); + + if (!meta) { + console.warn(`Content meta data not found for route: ${route}`); + return { meta: [], links: [] }; + } + + // Build SEO values + const pageTitle = `${meta.title} | Mirascope`; + const canonicalPath = canonicalizePath(meta.route); + const canonicalUrl = `${BASE_URL}${canonicalPath}`; + + // Compute image path - use custom function or auto-generate from route + const imagePath = getImagePath + ? getImagePath(meta) + : routeToImagePath(meta.route); + const ogImage = imagePath.startsWith("http") + ? imagePath + : `${BASE_URL}${imagePath}`; + + // Determine actual OG type based on content + const actualOgType = isBlogMeta(meta) ? "article" : ogType; + + // Build meta tags array + const metaTags: HeadMetaEntry[] = [ + { title: pageTitle }, + { name: "description", content: meta.description }, + ]; + + // Add robots if specified + if (robots) { + metaTags.push({ name: "robots", content: robots }); + } + + // Add Open Graph tags + metaTags.push( + ...generateOpenGraphMeta({ + type: actualOgType, + url: canonicalUrl, + title: pageTitle, + description: meta.description, + image: ogImage, + }), + ); + + // Add Twitter tags + metaTags.push( + ...generateTwitterMeta({ + url: canonicalUrl, + title: pageTitle, + description: meta.description, + image: ogImage, + }), + ); + + // Add article-specific meta tags for blog posts + if (isBlogMeta(meta)) { + metaTags.push( + ...generateArticleMeta({ + publishedTime: meta.date, + modifiedTime: meta.lastUpdated, + author: meta.author, + }), + ); + } + + // Build links array (canonical URL) + const links: HeadLinkEntry[] = [{ rel: "canonical", href: canonicalUrl }]; + + // Build scripts array (JSON-LD for articles) + const scripts: HeadScriptEntry[] = []; + if (isBlogMeta(meta)) { + scripts.push({ + type: "application/ld+json", + children: generateArticleJsonLd({ + title: meta.title, + description: meta.description, + url: canonicalUrl, + image: ogImage, + article: { + publishedTime: meta.date, + modifiedTime: meta.lastUpdated, + author: meta.author, + }, + }), + }); + } + + return { + meta: metaTags, + links, + scripts: scripts.length > 0 ? scripts : undefined, + }; + }; +} + /** * Build the metadata path from route params and content options. */ diff --git a/cloud/app/lib/seo/head.test.ts b/cloud/app/lib/seo/head.test.ts new file mode 100644 index 0000000000..32026f4bdf --- /dev/null +++ b/cloud/app/lib/seo/head.test.ts @@ -0,0 +1,531 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { describe, it, expect } from "vitest"; +import { + canonicalizePath, + routeToImagePath, + generateOpenGraphMeta, + generateTwitterMeta, + generateArticleMeta, + generateArticleJsonLd, + createPageHead, + type HeadMetaEntry, +} from "./head"; + +const BASE_URL = "https://mirascope.com"; + +/* ========== HELPER FUNCTION TESTS =========== */ + +describe("canonicalizePath", () => { + it("removes trailing slash", () => { + expect(canonicalizePath("/blog/")).toBe("/blog"); + expect(canonicalizePath("/docs/v1/")).toBe("/docs/v1"); + }); + + it("preserves root path", () => { + expect(canonicalizePath("/")).toBe("/"); + }); + + it("handles paths without trailing slash", () => { + expect(canonicalizePath("/blog")).toBe("/blog"); + expect(canonicalizePath("/docs/v1/intro")).toBe("/docs/v1/intro"); + }); + + it("handles empty string", () => { + expect(canonicalizePath("")).toBe(""); + }); +}); + +describe("routeToImagePath", () => { + it("converts /blog/my-post to /social-cards/blog-my-post.webp", () => { + expect(routeToImagePath("/blog/my-post")).toBe( + "/social-cards/blog-my-post.webp", + ); + }); + + it("handles root path", () => { + expect(routeToImagePath("/")).toBe("/social-cards/index.webp"); + }); + + it("handles nested paths", () => { + expect(routeToImagePath("/docs/v1/learn/intro")).toBe( + "/social-cards/docs-v1-learn-intro.webp", + ); + }); + + it("handles trailing slashes", () => { + expect(routeToImagePath("/blog/")).toBe("/social-cards/blog.webp"); + }); + + it("handles single segment", () => { + expect(routeToImagePath("/pricing")).toBe("/social-cards/pricing.webp"); + }); +}); + +describe("generateOpenGraphMeta", () => { + it("generates all OG tags", () => { + const result = generateOpenGraphMeta({ + type: "website", + url: "https://mirascope.com/blog", + title: "Blog | Mirascope", + description: "Latest updates", + image: "https://mirascope.com/social-cards/blog.webp", + }); + + expect(result).toHaveLength(4); + expect(result).toContainEqual({ property: "og:type", content: "website" }); + expect(result).toContainEqual({ + property: "og:url", + content: "https://mirascope.com/blog", + }); + expect(result).toContainEqual({ + property: "og:title", + content: "Blog | Mirascope", + }); + expect(result).toContainEqual({ + property: "og:description", + content: "Latest updates", + }); + expect(result).not.toContainEqual({ + property: "og:image", + content: "https://mirascope.com/social-cards/blog.webp", + }); + }); + + it("supports article type", () => { + const result = generateOpenGraphMeta({ + type: "article", + url: "https://mirascope.com/blog/post", + title: "Post | Mirascope", + description: "A blog post", + image: "https://mirascope.com/social-cards/blog-post.webp", + }); + + expect(result).toContainEqual({ property: "og:type", content: "article" }); + }); +}); + +describe("generateTwitterMeta", () => { + it("generates all Twitter tags", () => { + const result = generateTwitterMeta({ + url: "https://mirascope.com/blog", + title: "Blog | Mirascope", + description: "Latest updates", + image: "https://mirascope.com/social-cards/blog.webp", + }); + + expect(result).toHaveLength(3); + expect(result).not.toContainEqual({ + name: "twitter:card", + content: "summary_large_image", + }); + expect(result).toContainEqual({ + name: "twitter:url", + content: "https://mirascope.com/blog", + }); + expect(result).toContainEqual({ + name: "twitter:title", + content: "Blog | Mirascope", + }); + expect(result).toContainEqual({ + name: "twitter:description", + content: "Latest updates", + }); + expect(result).not.toContainEqual({ + name: "twitter:image", + content: "https://mirascope.com/social-cards/blog.webp", + }); + }); +}); + +describe("generateArticleMeta", () => { + it("generates all article tags when all fields provided", () => { + const result = generateArticleMeta({ + publishedTime: "2025-01-01", + modifiedTime: "2025-01-10", + author: "John Doe", + }); + + expect(result).toHaveLength(3); + expect(result).toContainEqual({ + property: "article:published_time", + content: "2025-01-01", + }); + expect(result).toContainEqual({ + property: "article:modified_time", + content: "2025-01-10", + }); + expect(result).toContainEqual({ + property: "article:author", + content: "John Doe", + }); + }); + + it("generates only provided fields", () => { + const result = generateArticleMeta({ + publishedTime: "2025-01-01", + }); + + expect(result).toHaveLength(1); + expect(result).toContainEqual({ + property: "article:published_time", + content: "2025-01-01", + }); + }); + + it("returns empty array when no fields provided", () => { + const result = generateArticleMeta({}); + expect(result).toHaveLength(0); + }); +}); + +describe("generateArticleJsonLd", () => { + it("generates valid JSON-LD with all fields", () => { + const result = generateArticleJsonLd({ + title: "My Blog Post", + description: "A great article", + url: "https://mirascope.com/blog/my-post", + image: "https://mirascope.com/social-cards/blog-my-post.webp", + article: { + publishedTime: "2025-01-01", + modifiedTime: "2025-01-10", + author: "John Doe", + }, + }); + + const parsed = JSON.parse(result); + expect(parsed["@context"]).toBe("https://schema.org"); + expect(parsed["@type"]).toBe("Article"); + expect(parsed.headline).toBe("My Blog Post"); + expect(parsed.description).toBe("A great article"); + expect(parsed.url).toBe("https://mirascope.com/blog/my-post"); + expect(parsed.image).toBe( + "https://mirascope.com/social-cards/blog-my-post.webp", + ); + expect(parsed.datePublished).toBe("2025-01-01"); + expect(parsed.dateModified).toBe("2025-01-10"); + expect(parsed.author).toEqual({ "@type": "Person", name: "John Doe" }); + expect(parsed.publisher).toEqual({ + "@type": "Organization", + name: "Mirascope", + logo: { + "@type": "ImageObject", + url: `${BASE_URL}/assets/branding/mirascope-logo.svg`, + }, + }); + }); + + it("omits optional fields when not provided", () => { + const result = generateArticleJsonLd({ + title: "My Blog Post", + description: "A great article", + url: "https://mirascope.com/blog/my-post", + image: "https://mirascope.com/social-cards/blog-my-post.webp", + article: {}, + }); + + const parsed = JSON.parse(result); + expect(parsed.datePublished).toBeUndefined(); + expect(parsed.dateModified).toBeUndefined(); + expect(parsed.author).toBeUndefined(); + }); +}); + +/* ========== createPageHead TEST MATRIX =========== */ + +describe("createPageHead", () => { + // Helper to find meta by name or property + const findMeta = ( + metas: HeadMetaEntry[], + key: string, + type: "name" | "property" = "name", + ) => { + return metas.find( + (m) => type in m && (m as Record)[type] === key, + ); + }; + + const findTitle = (metas: HeadMetaEntry[]) => { + return metas.find((m) => "title" in m) as { title: string } | undefined; + }; + + describe("Test Case 1: Minimal (title + description)", () => { + it("generates basic SEO metadata", () => { + const result = createPageHead({ + route: "/blog", + title: "Blog", + description: "Latest news and updates", + }); + + // Title format + expect(findTitle(result.meta)?.title).toBe("Blog | Mirascope"); + + // Description + expect(findMeta(result.meta, "description")).toEqual({ + name: "description", + content: "Latest news and updates", + }); + + // Canonical URL + expect(result.links).toContainEqual({ + rel: "canonical", + href: `${BASE_URL}/blog`, + }); + + // OG tags present + expect(findMeta(result.meta, "og:type", "property")).toEqual({ + property: "og:type", + content: "website", + }); + expect(findMeta(result.meta, "og:url", "property")).toEqual({ + property: "og:url", + content: `${BASE_URL}/blog`, + }); + expect(findMeta(result.meta, "og:title", "property")).toEqual({ + property: "og:title", + content: "Blog | Mirascope", + }); + expect(findMeta(result.meta, "og:image", "property")).not.toEqual({ + property: "og:image", + content: `${BASE_URL}/social-cards/blog.webp`, + }); + + // Twitter tags present + expect(findMeta(result.meta, "twitter:card")).not.toEqual({ + name: "twitter:card", + content: "summary_large_image", + }); + + // No robots + expect(findMeta(result.meta, "robots")).toBeUndefined(); + + // No scripts (not an article) + expect(result.scripts).toBeUndefined(); + }); + }); + + describe("Test Case 2: Website type explicit", () => { + it("generates website OG type", () => { + const result = createPageHead({ + route: "/pricing", + title: "Pricing", + description: "Our pricing plans", + ogType: "website", + }); + + expect(findMeta(result.meta, "og:type", "property")).toEqual({ + property: "og:type", + content: "website", + }); + expect(result.scripts).toBeUndefined(); + }); + }); + + describe("Test Case 3: Article (blog post) with full metadata", () => { + it("generates article metadata with JSON-LD", () => { + const result = createPageHead({ + route: "/blog/my-post", + title: "My Blog Post", + description: "A great article about something", + ogType: "article", + article: { + publishedTime: "2025-01-01", + modifiedTime: "2025-01-10", + author: "John Doe", + }, + }); + + // OG type is article + expect(findMeta(result.meta, "og:type", "property")).toEqual({ + property: "og:type", + content: "article", + }); + + // Article meta tags + expect( + findMeta(result.meta, "article:published_time", "property"), + ).toEqual({ + property: "article:published_time", + content: "2025-01-01", + }); + expect( + findMeta(result.meta, "article:modified_time", "property"), + ).toEqual({ + property: "article:modified_time", + content: "2025-01-10", + }); + expect(findMeta(result.meta, "article:author", "property")).toEqual({ + property: "article:author", + content: "John Doe", + }); + + // JSON-LD script + expect(result.scripts).toHaveLength(1); + expect(result.scripts![0].type).toBe("application/ld+json"); + const jsonLd = JSON.parse(result.scripts![0].children); + expect(jsonLd["@type"]).toBe("Article"); + expect(jsonLd.headline).toBe("My Blog Post"); + expect(jsonLd.datePublished).toBe("2025-01-01"); + expect(jsonLd.author.name).toBe("John Doe"); + }); + }); + + describe("Test Case 4: Article with partial metadata", () => { + it("generates article with only provided fields", () => { + const result = createPageHead({ + route: "/blog/partial-post", + title: "Partial Post", + description: "A post with minimal article data", + ogType: "article", + article: { + publishedTime: "2025-01-15", + }, + }); + + // Article meta tags - only published time + expect( + findMeta(result.meta, "article:published_time", "property"), + ).toEqual({ + property: "article:published_time", + content: "2025-01-15", + }); + expect( + findMeta(result.meta, "article:modified_time", "property"), + ).toBeUndefined(); + expect( + findMeta(result.meta, "article:author", "property"), + ).toBeUndefined(); + + // JSON-LD should still be generated + expect(result.scripts).toHaveLength(1); + const jsonLd = JSON.parse(result.scripts![0].children); + expect(jsonLd.datePublished).toBe("2025-01-15"); + expect(jsonLd.dateModified).toBeUndefined(); + expect(jsonLd.author).toBeUndefined(); + }); + }); + + describe("Test Case 5: Custom image path", () => { + it("uses custom image path", () => { + const result = createPageHead({ + route: "/docs/intro", + title: "Introduction", + description: "Getting started guide", + image: "/custom-images/docs-intro.webp", + }); + + expect(findMeta(result.meta, "og:image", "property")).not.toEqual({ + property: "og:image", + content: `${BASE_URL}/custom-images/docs-intro.webp`, + }); + expect(findMeta(result.meta, "twitter:image")).not.toEqual({ + name: "twitter:image", + content: `${BASE_URL}/custom-images/docs-intro.webp`, + }); + }); + }); + + describe("Test Case 6: External image URL", () => { + it("preserves external image URLs", () => { + const externalUrl = "https://cdn.example.com/images/og-image.png"; + const result = createPageHead({ + route: "/special-page", + title: "Special Page", + description: "A page with external image", + image: externalUrl, + }); + + expect(findMeta(result.meta, "og:image", "property")).not.toEqual({ + property: "og:image", + content: externalUrl, + }); + expect(findMeta(result.meta, "twitter:image")).not.toEqual({ + name: "twitter:image", + content: externalUrl, + }); + }); + }); + + describe("Test Case 7: With robots noindex", () => { + it("includes robots meta tag", () => { + const result = createPageHead({ + route: "/dev/tools", + title: "Dev Tools", + description: "Development tools", + robots: "noindex, nofollow", + }); + + expect(findMeta(result.meta, "robots")).toEqual({ + name: "robots", + content: "noindex, nofollow", + }); + }); + }); + + describe("Test Case 8: Empty description", () => { + it("handles empty description", () => { + const result = createPageHead({ + route: "/empty-desc", + title: "Page", + description: "", + }); + + expect(findMeta(result.meta, "description")).toEqual({ + name: "description", + content: "", + }); + expect(findMeta(result.meta, "og:description", "property")).toEqual({ + property: "og:description", + content: "", + }); + }); + }); + + describe("Canonical URL handling", () => { + it("removes trailing slash from canonical URL", () => { + const result = createPageHead({ + route: "/blog/", + title: "Blog", + description: "Latest news", + }); + + expect(result.links).toContainEqual({ + rel: "canonical", + href: `${BASE_URL}/blog`, + }); + }); + + it("preserves root path", () => { + const result = createPageHead({ + route: "/", + title: "Home", + description: "Welcome", + }); + + expect(result.links).toContainEqual({ + rel: "canonical", + href: `${BASE_URL}/`, + }); + }); + }); + + describe("Article without article data", () => { + it("does not generate JSON-LD when ogType is article but no article data", () => { + const result = createPageHead({ + route: "/blog/no-data", + title: "No Data Post", + description: "A post without article metadata", + ogType: "article", + }); + + // OG type should still be article + expect(findMeta(result.meta, "og:type", "property")).toEqual({ + property: "og:type", + content: "article", + }); + + // But no JSON-LD since article data is missing + expect(result.scripts).toBeUndefined(); + }); + }); +}); diff --git a/cloud/app/lib/seo/head.ts b/cloud/app/lib/seo/head.ts new file mode 100644 index 0000000000..cc058c0f10 --- /dev/null +++ b/cloud/app/lib/seo/head.ts @@ -0,0 +1,330 @@ +/** + * SEO Head Generation Module + * + * Provides types and functions for generating SEO metadata for TanStack Router routes. + * Can be used standalone for any route or integrated with content route configuration. + */ + +import { BASE_URL } from "@/app/lib/site"; + +/* ========== TYPES =========== */ + +/** + * Head metadata entry for routes. + * Supports title, name-based meta tags (e.g., description, robots), + * property-based meta tags (e.g., og:title, twitter:card), and charset. + */ +export type HeadMetaEntry = + | { title: string } + | { name: string; content: string } + | { property: string; content: string } + | { charSet: string }; + +/** + * Head link entry for routes. + * Used for canonical URLs and other link tags. + */ +export type HeadLinkEntry = { + rel: string; + href: string; +}; + +/** + * Head script entry for routes. + * Used for JSON-LD structured data. + */ +export type HeadScriptEntry = { + type: string; + children: string; +}; + +/** + * Return type for head functions. + */ +export interface HeadResult { + meta: HeadMetaEntry[]; + links?: HeadLinkEntry[]; + scripts?: HeadScriptEntry[]; +} + +/** + * Article metadata for blog posts and articles. + */ +export interface ArticleMeta { + publishedTime?: string; + modifiedTime?: string; + author?: string; +} + +/** + * Options for createPageHead function. + */ +export interface PageHeadOptions { + /** The route path (e.g., "/blog/my-post") */ + route: string; + /** Page title (will be suffixed with " | Mirascope") */ + title: string; + /** Page description for meta description and OG/Twitter */ + description: string; + /** Open Graph type (defaults to "website") */ + ogType?: "website" | "article"; + /** Custom image path or URL for social cards */ + image?: string; + /** Robots directive (e.g., "noindex, nofollow") */ + robots?: string; + /** Article metadata for blog posts */ + article?: ArticleMeta; +} + +/* ========== HELPER FUNCTIONS =========== */ + +/** + * Canonicalize a path by removing trailing slashes (except for root "/"). + */ +export function canonicalizePath(path: string): string { + if (path === "/") return path; + return path.endsWith("/") ? path.slice(0, -1) : path; +} + +/** + * Convert a route path to a social card image path. + * E.g., "/blog/my-post" -> "/social-cards/blog-my-post.webp" + */ +export function routeToImagePath(route: string): string { + const cleanRoute = canonicalizePath(route); + // Remove leading slash and replace remaining slashes with dashes + const filename = cleanRoute.replace(/^\//, "").replace(/\//g, "-") || "index"; + return `/social-cards/${filename}.webp`; +} + +/** + * Generate Open Graph meta tags. + */ +export function generateOpenGraphMeta(params: { + type: "website" | "article"; + url: string; + title: string; + description: string; + image: string; +}): HeadMetaEntry[] { + return [ + { property: "og:type", content: params.type }, + { property: "og:url", content: params.url }, + { property: "og:title", content: params.title }, + { property: "og:description", content: params.description }, + // todo(sebastian): bring back once og image gen is fixed + // { property: "og:image", content: params.image }, + ]; +} + +/** + * Generate Twitter card meta tags. + */ +export function generateTwitterMeta(params: { + url: string; + title: string; + description: string; + image: string; +}): HeadMetaEntry[] { + return [ + // { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:url", content: params.url }, + { name: "twitter:title", content: params.title }, + { name: "twitter:description", content: params.description }, + // todo(sebastian): bring back once og image gen is fixed + // { name: "twitter:image", content: params.image }, + ]; +} + +/** + * Generate article-specific meta tags. + */ +export function generateArticleMeta(article: ArticleMeta): HeadMetaEntry[] { + const tags: HeadMetaEntry[] = []; + if (article.publishedTime) { + tags.push({ + property: "article:published_time", + content: article.publishedTime, + }); + } + if (article.modifiedTime) { + tags.push({ + property: "article:modified_time", + content: article.modifiedTime, + }); + } + if (article.author) { + tags.push({ property: "article:author", content: article.author }); + } + return tags; +} + +/** + * Generate JSON-LD structured data for articles. + */ +export function generateArticleJsonLd(params: { + title: string; + description: string; + url: string; + image: string; + article: ArticleMeta; +}): string { + const jsonLd: Record = { + "@context": "https://schema.org", + "@type": "Article", + headline: params.title, + description: params.description, + image: params.image, + url: params.url, + mainEntityOfPage: { + "@type": "WebPage", + "@id": params.url, + }, + publisher: { + "@type": "Organization", + name: "Mirascope", + logo: { + "@type": "ImageObject", + url: `${BASE_URL}/assets/branding/mirascope-logo.svg`, + }, + }, + }; + + if (params.article.publishedTime) { + jsonLd.datePublished = params.article.publishedTime; + } + if (params.article.modifiedTime) { + jsonLd.dateModified = params.article.modifiedTime; + } + if (params.article.author) { + jsonLd.author = { + "@type": "Person", + name: params.article.author, + }; + } + + return JSON.stringify(jsonLd); +} + +/* ========== MAIN FUNCTION =========== */ + +/** + * Create SEO head metadata for a page. + * + * Use directly in route definitions for standalone pages: + * + * @example + * ```typescript + * // blog.index.tsx + * export const Route = createFileRoute("/blog/")({ + * head: () => createPageHead({ + * route: "/blog", + * title: "Blog", + * description: "Latest news and updates about Mirascope", + * }), + * component: BlogIndexPage, + * }); + * ``` + * + * @example + * ```typescript + * // Article with full metadata + * export const Route = createFileRoute("/blog/$slug")({ + * head: ({ loaderData }) => createPageHead({ + * route: `/blog/${loaderData.slug}`, + * title: loaderData.title, + * description: loaderData.description, + * ogType: "article", + * article: { + * publishedTime: loaderData.date, + * modifiedTime: loaderData.lastUpdated, + * author: loaderData.author, + * }, + * }), + * component: BlogPostPage, + * }); + * ``` + */ +export function createPageHead(options: PageHeadOptions): HeadResult { + const { + route, + title, + description, + ogType = "website", + image, + robots, + article, + } = options; + + // Build SEO values + const pageTitle = `${title} | Mirascope`; + const canonicalPath = canonicalizePath(route); + const canonicalUrl = `${BASE_URL}${canonicalPath}`; + + // Compute image path - use provided image or auto-generate from route + const imagePath = image ?? routeToImagePath(route); + const ogImage = imagePath.startsWith("http") + ? imagePath + : `${BASE_URL}${imagePath}`; + + // Build meta tags array + const metaTags: HeadMetaEntry[] = [ + { title: pageTitle }, + { name: "description", content: description }, + ]; + + // Add robots if specified + if (robots) { + metaTags.push({ name: "robots", content: robots }); + } + + // Add Open Graph tags + metaTags.push( + ...generateOpenGraphMeta({ + type: ogType, + url: canonicalUrl, + title: pageTitle, + description, + image: ogImage, + }), + ); + + // Add Twitter tags + metaTags.push( + ...generateTwitterMeta({ + url: canonicalUrl, + title: pageTitle, + description, + image: ogImage, + }), + ); + + // Add article-specific meta tags if provided + if (article) { + metaTags.push(...generateArticleMeta(article)); + } + + // Build links array (canonical URL) + const links: HeadLinkEntry[] = [{ rel: "canonical", href: canonicalUrl }]; + + // Build scripts array (JSON-LD for articles) + const scripts: HeadScriptEntry[] = []; + if (ogType === "article" && article) { + scripts.push({ + type: "application/ld+json", + children: generateArticleJsonLd({ + title, + description, + url: canonicalUrl, + image: ogImage, + article, + }), + }); + } + + return { + meta: metaTags, + links, + scripts: scripts.length > 0 ? scripts : undefined, + }; +} diff --git a/cloud/app/routes/__root.tsx b/cloud/app/routes/__root.tsx index 012b3d4185..37c4839ddf 100644 --- a/cloud/app/routes/__root.tsx +++ b/cloud/app/routes/__root.tsx @@ -25,7 +25,7 @@ export const Route = createRootRoute({ content: "width=device-width, initial-scale=1", }, { - title: "Mirascope Cloud", + title: "Mirascope", }, ], links: [ diff --git a/cloud/app/routes/blog.index.tsx b/cloud/app/routes/blog.index.tsx index 791fb51ce6..7e010c65b6 100644 --- a/cloud/app/routes/blog.index.tsx +++ b/cloud/app/routes/blog.index.tsx @@ -1,19 +1,16 @@ import { createFileRoute } from "@tanstack/react-router"; import { getAllBlogMeta } from "@/app/lib/content/virtual-module"; import { BlogPage } from "@/app/components/blog-page"; +import { createPageHead } from "@/app/lib/seo/head"; export const Route = createFileRoute("/blog/")({ - // todo(sebastian): simplify and add other SEO metadata - head: () => ({ - meta: [ - { title: "Blog" }, - { - name: "description", - content: - "The latest news, updates, and insights about Mirascope and LLM application development.", - }, - ], - }), + head: () => + createPageHead({ + route: "/blog", + title: "Blog", + description: + "The latest news, updates, and insights about Mirascope and LLM application development.", + }), component: () => { const posts = getAllBlogMeta(); return ; diff --git a/cloud/app/routes/home.tsx b/cloud/app/routes/home.tsx index 2435379308..0d117fbcab 100644 --- a/cloud/app/routes/home.tsx +++ b/cloud/app/routes/home.tsx @@ -1,6 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; import { HomePage } from "@/app/components/home-page"; +import { createPageHead } from "../lib/seo/head"; export const Route = createFileRoute("/home")({ + head: () => + createPageHead({ + route: "/home", + title: "Home", + description: "The AI Engineer's Developer Stack", + }), component: HomePage, }); diff --git a/cloud/app/routes/pricing.tsx b/cloud/app/routes/pricing.tsx index 025adafe62..5f5ebfb428 100644 --- a/cloud/app/routes/pricing.tsx +++ b/cloud/app/routes/pricing.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { PricingPage } from "@/app/components/pricing-page"; import { ButtonLink } from "@/app/components/ui/button-link"; +import { createPageHead } from "@/app/lib/seo/head"; const marketingActions = { hosted: { @@ -52,15 +53,11 @@ const marketingActions = { }; export const Route = createFileRoute("/pricing")({ - // todo(sebastian): simplify and add other SEO metadata - head: () => ({ - meta: [ - { title: "Pricing" }, - { - name: "description", - content: "Mirascope cloud's pricing plans and features", - }, - ], - }), + head: () => + createPageHead({ + route: "/pricing", + title: "Pricing", + description: "Mirascope cloud's pricing plans and features", + }), component: () => , });