diff --git a/cloud/app/components/blog-post-page.tsx b/cloud/app/components/blog-post-page.tsx index 0a2abc5b96..9603ffb149 100644 --- a/cloud/app/components/blog-post-page.tsx +++ b/cloud/app/components/blog-post-page.tsx @@ -4,7 +4,6 @@ import { MDXRenderer } from "@/app/components/mdx/renderer"; import { CopyMarkdownButton } from "@/app/components/blocks/copy-markdown-button"; import LoadingContent from "@/app/components/blocks/loading-content"; import { ContentTOC } from "@/app/components/content-toc"; -// import { PagefindMeta } from "@/app/components/pagefind-meta"; import type { BlogContent } from "@/app/lib/content/types"; import ContentLayout from "@/app/components/content-layout"; import { useEffect, useState } from "react"; @@ -22,23 +21,17 @@ function BackToBlogLink() { } type BlogPostPageProps = { - post: BlogContent; - slug: string; - isLoading?: boolean; + content: BlogContent; }; -export function BlogPostPage({ - post, - slug, - isLoading = false, -}: BlogPostPageProps) { +export function BlogPostPage({ content }: BlogPostPageProps) { + const slug = content.meta.slug; + // todo(sebastian): disabled - did this work before? const [, setOgImage] = useState(undefined); // Find the first available image in the blog post directory useEffect(() => { - if (isLoading) return; - const findOgImage = async () => { try { const response = await fetch(`/assets/blog/${slug}/`); @@ -60,15 +53,13 @@ export function BlogPostPage({ }; void findOgImage(); - }, [slug, isLoading]); + }, [slug]); // Extract metadata for easier access - const { title, date, readTime, author, lastUpdated } = post.meta; + const { title, date, readTime, author, lastUpdated } = content.meta; // Main content - const mainContent = isLoading ? ( - - ) : ( + const mainContent = (
@@ -85,41 +76,25 @@ export function BlogPostPage({ )}
- {post.mdx ? ( + {content.mdx ? ( ) : ( )} - {/* todo(sebastian): re-enable when PagefindMeta is implemented */} - {/* {post.mdx ? ( - - - - ) : ( - - )} */}
); - // Right sidebar content - loading state or actual content - const rightSidebarContent = isLoading ? ( -
-
-
- ) : ( + // Right sidebar content + const rightSidebarContent = (
@@ -130,9 +105,8 @@ export function BlogPostPage({
- {/* todo(sebastian): Make sure the headings have IDs in the mdx content */}
@@ -141,17 +115,6 @@ export function BlogPostPage({ return ( <> - {/* */}
@@ -162,7 +125,7 @@ export function BlogPostPage({ {mainContent} diff --git a/cloud/app/components/docs-page.tsx b/cloud/app/components/docs-page.tsx index bf920d9c1b..17d19e92b3 100644 --- a/cloud/app/components/docs-page.tsx +++ b/cloud/app/components/docs-page.tsx @@ -1,6 +1,5 @@ import React from "react"; import ContentLayout from "@/app/components/content-layout"; -import LoadingContent from "@/app/components/blocks/loading-content"; import { ModelProviderProvider } from "@/app/components/mdx/elements/model-provider-provider"; import DocsTocSidebar from "@/app/components/docs-toc-sidebar"; import MainContent from "@/app/components/blocks/docs/main-content"; @@ -8,17 +7,15 @@ import DocsSidebar from "@/app/components/docs-sidebar"; import type { DocContent } from "@/app/lib/content/types"; type DocsPageProps = { - document?: DocContent; - isLoading?: boolean; + content: DocContent; }; /** * DocsPage component - Top-level documentation page component * * Handles metadata, layout and content rendering for all documentation pages - * Supports both loaded and loading states */ -const DocsPage: React.FC = ({ document, isLoading = false }) => { +const DocsPage: React.FC = ({ content }) => { return ( <> @@ -28,11 +25,7 @@ const DocsPage: React.FC = ({ document, isLoading = false }) => { - {isLoading ? ( - - ) : ( - document && - )} + = ({ document, isLoading = false }) => { mobileCollapsible={true} mobileTitle="On this page" > - {isLoading ? ( -
-
-
- ) : ( - document && - )} +
diff --git a/cloud/app/lib/content/content-loader.test.ts b/cloud/app/lib/content/content-loader.test.ts deleted file mode 100644 index 0c88de3a53..0000000000 --- a/cloud/app/lib/content/content-loader.test.ts +++ /dev/null @@ -1,664 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { LoaderFnContext, AnyRoute } from "@tanstack/react-router"; -import { - getAllBlogMeta, - getAllDocsMeta, -} from "@/app/lib/content/virtual-module"; -import { - blogPostContentLoader, - docsContentLoader, -} from "@/app/lib/content/content-loader"; -import { createProcessedMDX } from "@/app/lib/content/mdx-compile"; -import type { ProcessedMDX } from "@/app/lib/mdx/types"; - -// Mock the virtual module -vi.mock("virtual:content-meta", () => ({ - blogMetadata: [ - { - slug: "test-post", - title: "Test Post", - description: "A test blog post", - date: "2025-01-01", - author: "Test Author", - readTime: "5 min read", - lastUpdated: "", - path: "blog/test-post", - type: "blog" as const, - route: "/blog/test-post", - }, - { - slug: "another-post", - title: "Another Post", - description: "Another test blog post", - date: "2025-01-02", - author: "Test Author", - readTime: "3 min read", - lastUpdated: "", - path: "blog/another-post", - type: "blog" as const, - route: "/blog/another-post", - }, - ], - docsMetadata: [ - { - label: "Test Doc", - path: "docs/v1/learn/test-doc", - routePath: "/docs/v1/learn/test-doc", - slug: "test-doc", - type: "docs" as const, - searchWeight: 1.0, - }, - { - label: "Another Doc", - path: "docs/v1/learn/another-doc", - routePath: "/docs/v1/learn/another-doc", - slug: "another-doc", - type: "docs" as const, - searchWeight: 1.0, - }, - { - label: "Index Page", - path: "docs/v1/index", - routePath: "/docs/v1", - slug: "index", - type: "docs" as const, - searchWeight: 1.0, - }, - { - label: "Non-Versioned Doc", - path: "docs/learn/non-versioned-doc", - routePath: "/docs/learn/non-versioned-doc", - slug: "non-versioned-doc", - type: "docs" as const, - searchWeight: 1.0, - }, - { - label: "Non-Versioned Index", - path: "docs/index", - routePath: "/docs", - slug: "index", - type: "docs" as const, - searchWeight: 1.0, - }, - ], -})); - -// Test MDX content fixture -const testMDXContent = "# Test Content\n\nThis is test content."; - -/** - * Helper function to create a test module map. - * This avoids needing eslint-disable comments for 'any' types throughout the tests. - */ -function createTestModuleMap(): Map< - string, - () => Promise<{ mdx: ProcessedMDX }> -> { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return new Map Promise<{ mdx: any }>>(); -} - -describe("blogPostContentLoader", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - function createMockContext( - slug: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): LoaderFnContext { - return { - params: { slug }, - abortController: new AbortController(), - preload: false, - deps: {}, - context: {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - location: {} as any, - navigate: async () => {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - parentMatchPromise: Promise.resolve({} as any), - cause: "enter" as const, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - route: {} as any, - }; - } - - it("should return undefined for non-existent blog post (fails allow list check)", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("non-existent-post"); - const result = await loader(context); - - // Should return undefined because it's not in the allow list - expect(result).toBeUndefined(); - }); - - it("should successfully load a valid blog post", async () => { - // Set up a custom module map with test content - const customModuleMap = createTestModuleMap(); - customModuleMap.set("test-post", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Test Post", - description: "A test blog post", - date: "2025-01-01", - author: "Test Author", - }), - }), - ); - - // Pass custom module map for testing - const loader = blogPostContentLoader(customModuleMap); - const context = createMockContext("test-post"); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.meta.slug).toBe("test-post"); - expect(result?.meta.title).toBe("Test Post"); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx).toBeDefined(); - expect(result?.mdx.content).toBe(testMDXContent); - }); - - describe("appsec path traversal attacks", () => { - it("should reject path traversal with ../", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("../../../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with ..\\", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("..\\..\\..\\windows\\system32"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with encoded ../", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext( - "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", - ); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with double-encoded ../", async () => { - const loader = blogPostContentLoader(); - // Double encoding: %25 = %, so %252e = %2e which decodes to . - const context = createMockContext( - "%252e%252e%252f%252e%252e%252fetc%252fpasswd", - ); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with mixed slashes", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("..\\../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject absolute paths", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("/etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with forward slashes", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("blog/../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with backslashes", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("blog\\..\\etc\\passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec invalid characters", () => { - it("should reject slugs with null bytes", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("test-post\0"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject slugs with special shell characters", async () => { - const loader = blogPostContentLoader(); - const maliciousSlugs = [ - "test-post; rm -rf /", - "test-post | cat /etc/passwd", - "test-post && cat /etc/passwd", - "test-post $(cat /etc/passwd)", - "test-post `cat /etc/passwd`", - ]; - - for (const slug of maliciousSlugs) { - const context = createMockContext(slug); - const result = await loader(context); - expect(result).toBeUndefined(); - } - }); - - it("should reject slugs with unicode characters that could be dangerous", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("test-post\u202e"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec allow list validation", () => { - it("should reject slugs not in the allow list", async () => { - const loader = blogPostContentLoader(); - const invalidSlugs = [ - "random-slug", - "prompt-testing", // not in our mock allow list - "fake-post", - "", - ]; - - for (const slug of invalidSlugs) { - const context = createMockContext(slug); - const result = await loader(context); - expect(result).toBeUndefined(); - } - }); - - it("should verify allow list is properly constructed", () => { - const meta = getAllBlogMeta(); - const validSlugSet = new Set(meta.map((post) => post.slug)); - - // Verify our mock data is in the set - expect(validSlugSet.has("test-post")).toBe(true); - expect(validSlugSet.has("another-post")).toBe(true); - expect(validSlugSet.size).toBe(2); - }); - }); - - describe("edge cases", () => { - it("should handle empty slug", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext(""); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle very long slug", async () => { - const loader = blogPostContentLoader(); - const longSlug = "a".repeat(1000); - const context = createMockContext(longSlug); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle slug with only special characters", async () => { - const loader = blogPostContentLoader(); - const context = createMockContext("!!!@@@###$$$"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); -}); - -describe("docsContentLoader", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - function createMockContext( - splat: string | undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): LoaderFnContext { - return { - params: { _splat: splat }, - abortController: new AbortController(), - preload: false, - deps: {}, - context: {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - location: {} as any, - navigate: async () => {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - parentMatchPromise: Promise.resolve({} as any), - cause: "enter" as const, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - route: {} as any, - }; - } - - it("should return undefined for non-existent doc (fails allow list check)", async () => { - const loader = docsContentLoader("v1"); - const context = createMockContext("learn/non-existent-doc"); - const result = await loader(context); - - // Should return undefined because it's not in the allow list - expect(result).toBeUndefined(); - }); - - it("should successfully load a valid doc", async () => { - // Set up a custom module map with test content - // Note: module map uses 'path' (v1/learn/test-doc), not 'routePath' - const customModuleMap = createTestModuleMap(); - customModuleMap.set("v1/learn/test-doc", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Test Doc", - description: "A test doc", - }), - }), - ); - - // Pass custom module map for testing - const loader = docsContentLoader("v1", customModuleMap); - const context = createMockContext("learn/test-doc"); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx.frontmatter?.title).toBe("Test Doc"); - }); - - it("should handle index page (empty _splat)", async () => { - const customModuleMap = createTestModuleMap(); - customModuleMap.set("v1/index", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Index Page", - description: "Index page", - }), - }), - ); - - const loader = docsContentLoader("v1", customModuleMap); - const context = createMockContext(undefined); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx.frontmatter?.title).toBe("Index Page"); - }); - - it("should handle different versions correctly", async () => { - const customModuleMap = createTestModuleMap(); - customModuleMap.set("v2/learn/test-doc", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "V2 Doc", - description: "V2 doc", - }), - }), - ); - - // Create a custom loader with v2 version - // Note: This test verifies version parameter is used correctly in routePath construction - const loader = docsContentLoader("v2", customModuleMap); - const context = createMockContext("learn/test-doc"); - const result = await loader(context); - - // Should fail because our mock docInfos only have v1 routes - // This verifies that version is correctly used in routePath matching - expect(result).toBeUndefined(); - }); - - it("should successfully load a non-versioned doc", async () => { - const customModuleMap = createTestModuleMap(); - customModuleMap.set("learn/non-versioned-doc", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Non-Versioned Doc", - description: "A non-versioned doc", - }), - }), - ); - - // Loader without version parameter - const loader = docsContentLoader(undefined, customModuleMap); - const context = createMockContext("learn/non-versioned-doc"); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx.frontmatter?.title).toBe("Non-Versioned Doc"); - }); - - it("should handle non-versioned index page (empty _splat)", async () => { - const customModuleMap = createTestModuleMap(); - customModuleMap.set("index", () => - Promise.resolve({ - mdx: createProcessedMDX(testMDXContent, { - title: "Non-Versioned Index", - description: "Non-versioned index page", - }), - }), - ); - - // Loader without version parameter - const loader = docsContentLoader(undefined, customModuleMap); - const context = createMockContext(undefined); - const result = await loader(context); - - expect(result).toBeDefined(); - expect(result?.content).toBe(testMDXContent); - expect(result?.mdx.frontmatter?.title).toBe("Non-Versioned Index"); - }); - - it("should not load versioned docs when version is not specified", async () => { - // Try to access v1 doc without specifying version - const loader = docsContentLoader(); - const context = createMockContext("learn/test-doc"); - const result = await loader(context); - - // Should fail because routePath won't match (/docs/learn/test-doc vs /docs/v1/learn/test-doc) - expect(result).toBeUndefined(); - }); - - it("should not load non-versioned docs when version is specified", async () => { - // Try to access non-versioned doc with v1 version specified - const loader = docsContentLoader("v1"); - const context = createMockContext("learn/non-versioned-doc"); - const result = await loader(context); - - // Should fail because routePath won't match (/docs/v1/learn/non-versioned-doc vs /docs/learn/non-versioned-doc) - expect(result).toBeUndefined(); - }); - - describe("appsec path traversal attacks", () => { - // Use empty module map to avoid loading real files during security tests - const emptyModuleMap = createTestModuleMap(); - - it("should reject path traversal with ../", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("../../../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with ..\\", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("..\\..\\..\\windows\\system32"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with encoded ../", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext( - "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", - ); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with double-encoded ../", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - // Double encoding: %25 = %, so %252e = %2e which decodes to . - const context = createMockContext( - "%252e%252e%252f%252e%252e%252fetc%252fpasswd", - ); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with mixed slashes", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("..\\../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject absolute paths", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("/etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with forward slashes in _splat", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("learn/../etc/passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with backslashes in _splat", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("learn\\..\\etc\\passwd"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec invalid characters", () => { - // Use empty module map to avoid loading real files during security tests - const emptyModuleMap = createTestModuleMap(); - - it("should reject _splat with null bytes", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("learn/test-doc\0"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject _splat with special shell characters", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const maliciousSplats = [ - "learn/test-doc; rm -rf /", - "learn/test-doc | cat /etc/passwd", - "learn/test-doc && cat /etc/passwd", - "learn/test-doc $(cat /etc/passwd)", - "learn/test-doc `cat /etc/passwd`", - ]; - - for (const splat of maliciousSplats) { - const context = createMockContext(splat); - const result = await loader(context); - expect(result).toBeUndefined(); - } - }); - - it("should reject _splat with unicode characters that could be dangerous", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("learn/test-doc\u202e"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec allow list validation", () => { - // Use empty module map to avoid loading real files during security tests - const emptyModuleMap = createTestModuleMap(); - - it("should reject _splat not in the allow list", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const invalidSplats = [ - "random/path", - "learn/fake-doc", // not in our mock allow list - "v1/learn/test-doc", // includes version prefix (shouldn't) - "", - ]; - - for (const splat of invalidSplats) { - const context = createMockContext(splat); - const result = await loader(context); - expect(result).toBeUndefined(); - } - }); - - it("should verify allow list is properly constructed", () => { - const docInfos = getAllDocsMeta(); - const validPathSet = new Set(docInfos.map((doc) => doc.path)); - - // Verify our mock data is in the set (paths with "docs/" prefix) - expect(validPathSet.has("docs/v1/learn/test-doc")).toBe(true); - expect(validPathSet.has("docs/v1/learn/another-doc")).toBe(true); - expect(validPathSet.has("docs/v1/index")).toBe(true); - expect(validPathSet.has("docs/learn/non-versioned-doc")).toBe(true); - expect(validPathSet.has("docs/index")).toBe(true); - expect(validPathSet.size).toBe(5); - }); - }); - - describe("edge cases", () => { - // Use empty module map to avoid loading real files during security tests - const emptyModuleMap = createTestModuleMap(); - - it("should handle very long _splat", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const longSplat = "a".repeat(1000); - const context = createMockContext(longSplat); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle _splat with only special characters", async () => { - const loader = docsContentLoader("v1", emptyModuleMap); - const context = createMockContext("!!!@@@###$$$"); - const result = await loader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle version mismatch", async () => { - // Try to access v1 doc with v2 loader - const loader = docsContentLoader("v2", emptyModuleMap); - const context = createMockContext("learn/test-doc"); - const result = await loader(context); - - // Should fail because routePath won't match - expect(result).toBeUndefined(); - }); - }); -}); diff --git a/cloud/app/lib/content/content-loader.ts b/cloud/app/lib/content/content-loader.ts deleted file mode 100644 index 036d019861..0000000000 --- a/cloud/app/lib/content/content-loader.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { LoaderFnContext } from "@tanstack/react-router"; -import type { - BlogContent, - BlogMeta, - DocContent, - DocMeta, - PolicyContent, - PolicyMeta, -} from "@/app/lib/content/types"; -import type { ProcessedMDX } from "@/app/lib/mdx/types"; -import { - BLOG_MODULE_MAP, - DOCS_MODULE_MAP, - POLICY_MODULE_MAP, - getAllBlogMeta, - getAllDocsMeta, - getAllPolicyMeta, - type ModuleMap, -} from "./virtual-module"; - -/** - * Config for making a content loader by type. - */ -interface ContentLoaderConfig< - TMeta, - TParams extends Record, - TContent, -> { - /** Function to get all metadata for this content type */ - getMeta: () => TMeta[]; - /** Extract the lookup key from route params */ - extractKey: (params: TParams) => string; - /** Find metadata matching the extracted key (validates against allow list) */ - findMeta: (metas: TMeta[], key: string) => TMeta | undefined; - /** Get the module map key from metadata (for looking up the MDX loader) */ - getModuleKey: (meta: TMeta, params: TParams) => string; - /** Build the final content object from metadata and loaded MDX */ - buildContent: (meta: TMeta, mdx: ProcessedMDX) => TContent; -} - -/** - * Create a generic, type-safe content loader for TanStack Router. - * - * @param moduleMap - Map from content key to module loader - * @param config - Content-type-specific configuration - */ -function createContentLoader< - TMeta, - TParams extends Record, - TContent, ->(moduleMap: ModuleMap, config: ContentLoaderConfig) { - return async function loader( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: LoaderFnContext, - ): Promise { - // Type assertion needed due to TanStack Router's complex param type expansion - const key = config.extractKey(context.params as TParams); - - const metas = config.getMeta(); - - // Validate key against allow list (security: prevents path traversal) - const meta = config.findMeta(metas, key); - if (!meta) { - return undefined; - } - - const moduleKey = config.getModuleKey(meta, context.params as TParams); - const moduleLoader = moduleMap.get(moduleKey); - if (!moduleLoader) { - return undefined; - } - - const module = await moduleLoader(); - return config.buildContent(meta, module.mdx); - }; -} - -/* ========== BLOG CONTENT LOADER =========== */ - -/** - * Type for the data returned by blogPostContentLoader. - */ -export type BlogPostLoaderData = BlogContent | undefined; - -/** - * Blog post content loader (supports custom module map for testing). - * - * @param moduleMap - Optional custom module map (for testing) - */ -export function blogPostContentLoader(moduleMap?: ModuleMap) { - const effectiveModuleMap = moduleMap ?? BLOG_MODULE_MAP; - - return createContentLoader( - effectiveModuleMap, - { - getMeta: getAllBlogMeta, - extractKey: (params) => params.slug, - findMeta: (metas, slug) => metas.find((m) => m.slug === slug), - getModuleKey: (meta) => meta.slug, - buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), - }, - ); -} - -/* ========== DOCS CONTENT LOADER =========== */ - -/** - * Type for the data returned by docsContentLoader. - */ -export type DocsLoaderData = DocContent | undefined; - -/** - * Docs content loader (supports custom module map for testing). - * - * @param version - Optional docs version (e.g., "v1"). If omitted, loads non-versioned docs. - * @param moduleMap - Optional custom module map (for testing) - */ -export function docsContentLoader(version?: string, moduleMap?: ModuleMap) { - const effectiveModuleMap = moduleMap ?? DOCS_MODULE_MAP; - - return createContentLoader( - effectiveModuleMap, - { - getMeta: getAllDocsMeta, - extractKey: (params) => { - // Build path with "docs/" prefix to match DocInfo.path format - const basePath = version ? `docs/${version}` : "docs"; - return `${basePath}${params._splat ? `/${params._splat}` : ""}`; - }, - findMeta: (metas, path) => { - // First try exact match - const exactMatch = metas.find((m) => m.path === path); - if (exactMatch) { - return exactMatch; - } - // For index pages, the URL won't include "/index" but the metadata path will - // e.g., URL "/docs/v1/guides" -> metadata path "docs/v1/guides/index" - return metas.find((m) => m.path === `${path}/index`); - }, - getModuleKey: (meta) => { - // Strip "docs/" prefix to get the module key (file path relative to content/docs/) - const prefix = "docs/"; - return meta.path.startsWith(prefix) - ? meta.path.slice(prefix.length) - : meta.path; - }, - buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), - }, - ); -} - -/* ========== POLICY CONTENT LOADER =========== */ - -/** - * Type for the data returned by policyContentLoader. - */ -export type PolicyLoaderData = PolicyContent | undefined; - -/** - * Generic policy content loader (supports custom module map for testing). - * - * @param subdirectory - Optional subdirectory within policy (e.g., "terms" for policy/terms/, - * or undefined for top-level policy files like privacy.mdx) - * @param moduleMap - Optional custom module map (for testing) - */ -export function policyContentLoader( - subdirectory: string | undefined, - moduleMap?: ModuleMap, -) { - const effectiveModuleMap = moduleMap ?? POLICY_MODULE_MAP; - - return createContentLoader( - effectiveModuleMap, - { - getMeta: getAllPolicyMeta, - extractKey: (params) => { - // Build path with "policy/" prefix, optionally including subdirectory - const basePath = subdirectory ? `policy/${subdirectory}` : "policy"; - return `${basePath}${params._splat ? `/${params._splat}` : ""}`; - }, - findMeta: (metas, path) => { - // Find metadata matching the path (e.g., "policy/privacy" or "policy/terms/service") - return metas.find((m) => m.path === path); - }, - getModuleKey: (meta) => { - // Strip "policy/" prefix to get the module key - const prefix = "policy/"; - return meta.path.startsWith(prefix) - ? meta.path.slice(prefix.length) - : meta.path; - }, - buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), - }, - ); -} - -/** - * Terms content loader - convenience wrapper for policy/terms/ subdirectory. - * - * @param moduleMap - Optional custom module map (for testing) - */ -export function termsContentLoader(moduleMap?: ModuleMap) { - return policyContentLoader("terms", moduleMap); -} - -/** - * Privacy content loader - convenience wrapper for top-level policy/privacy. - * This loader doesn't use _splat params since privacy is a single file. - * - * @param moduleMap - Optional custom module map (for testing) - */ -export function privacyContentLoader(moduleMap?: ModuleMap) { - const effectiveModuleMap = moduleMap ?? POLICY_MODULE_MAP; - - return createContentLoader, PolicyContent>( - effectiveModuleMap, - { - getMeta: getAllPolicyMeta, - extractKey: () => { - // Privacy is always at "policy/privacy" - return "policy/privacy"; - }, - findMeta: (metas, path) => { - return metas.find((m) => m.path === path); - }, - getModuleKey: (meta) => { - // Strip "policy/" prefix to get the module key - const prefix = "policy/"; - return meta.path.startsWith(prefix) - ? meta.path.slice(prefix.length) - : meta.path; - }, - buildContent: (meta, mdx) => ({ meta, content: mdx.content, mdx }), - }, - ); -} diff --git a/cloud/app/lib/content/route-config.test.tsx b/cloud/app/lib/content/route-config.test.tsx new file mode 100644 index 0000000000..32ac651c7b --- /dev/null +++ b/cloud/app/lib/content/route-config.test.tsx @@ -0,0 +1,964 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + BLOG_MODULE_MAP, + DOCS_MODULE_MAP, + POLICY_MODULE_MAP, + getAllBlogMeta, + getAllDocsMeta, + getAllPolicyMeta, +} from "@/app/lib/content/virtual-module"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; +import { createProcessedMDX } from "@/app/lib/content/mdx-compile"; +import type { ProcessedMDX } from "@/app/lib/mdx/types"; +import type { BlogContent, Content, DocContent } from "@/app/lib/content/types"; + +// Mock TanStack Router +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: vi.fn((path: string) => (options: unknown) => ({ + path, + options, + })), + redirect: vi.fn((config: unknown) => { + const error = new Error("Redirect"); + (error as unknown as { redirect: unknown }).redirect = config; + throw error; + }), + useLoaderData: vi.fn(), +})); + +// Mock NotFound component +vi.mock("@/app/components/not-found", () => ({ + NotFound: () => null, +})); + +// Mock the virtual module +vi.mock("virtual:content-meta", () => ({ + blogMetadata: [ + { + slug: "test-post", + title: "Test Post", + description: "A test blog post", + date: "2025-01-01", + author: "Test Author", + readTime: "5 min read", + lastUpdated: "", + path: "blog/test-post", + type: "blog" as const, + route: "/blog/test-post", + }, + { + slug: "another-post", + title: "Another Post", + description: "Another test blog post", + date: "2025-01-02", + author: "Test Author", + readTime: "3 min read", + lastUpdated: "", + path: "blog/another-post", + type: "blog" as const, + route: "/blog/another-post", + }, + ], + docsMetadata: [ + { + title: "Test Doc", + description: "A test doc", + path: "docs/v1/learn/test-doc", + routePath: "/docs/v1/learn/test-doc", + slug: "test-doc", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs>v1>learn", + }, + { + title: "Another Doc", + description: "Another test doc", + path: "docs/v1/learn/another-doc", + routePath: "/docs/v1/learn/another-doc", + slug: "another-doc", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs>v1>learn", + }, + { + title: "Index Page", + description: "Index page", + path: "docs/v1/index", + routePath: "/docs/v1", + slug: "index", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs>v1", + }, + { + title: "Non-Versioned Doc", + description: "A non-versioned doc", + path: "docs/learn/non-versioned-doc", + routePath: "/docs/learn/non-versioned-doc", + slug: "non-versioned-doc", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs>learn", + }, + { + title: "Non-Versioned Index", + description: "Non-versioned index page", + path: "docs/index", + routePath: "/docs", + slug: "index", + type: "docs" as const, + searchWeight: 1.0, + sectionPath: "docs", + }, + ], + policyMetadata: [], +})); + +// Test MDX content fixture +const testMDXContent = "# Test Content\n\nThis is test content."; + +/** + * Helper function to create a test module map. + */ +function createTestModuleMap(): Map< + string, + () => Promise<{ mdx: ProcessedMDX }> +> { + return new Map Promise<{ mdx: ProcessedMDX }>>(); +} + +/** + * Helper to get the loader from a route config created by createContentRouteConfig. + */ + +function getLoader( + routeConfig: ReturnType>, +) { + return routeConfig.loader; +} + +/** + * Helper to create a mock loader context. + */ +function createMockContext(params: Record) { + return { + params, + abortController: new AbortController(), + preload: false, + deps: {}, + context: {}, + location: {} as unknown, + navigate: async () => {}, + parentMatchPromise: Promise.resolve({} as unknown), + cause: "enter" as const, + route: {} as unknown, + }; +} + +// Dummy component for testing - accepts base Content type for flexibility +const DummyComponent = ({ content }: { content: Content }) => { + void content; + return null; +}; + +describe("createContentRouteConfig - blog", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return undefined for non-existent blog post (fails allow list check)", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "non-existent-post" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should successfully load a valid blog post", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("test-post", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Test Post", + description: "A test blog post", + date: "2025-01-01", + author: "Test Author", + }), + }), + ); + + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "test-post" }); + const result = (await loader(context)) as BlogContent | undefined; + + expect(result).toBeDefined(); + expect(result?.meta.slug).toBe("test-post"); + expect(result?.meta.title).toBe("Test Post"); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx).toBeDefined(); + expect(result?.mdx.content).toBe(testMDXContent); + }); + + describe("appsec path traversal attacks", () => { + it("should reject path traversal with ../", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "../../../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with ..\\", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + slug: "..\\..\\..\\windows\\system32", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with encoded ../", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + slug: "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with double-encoded ../", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + slug: "%252e%252e%252f%252e%252e%252fetc%252fpasswd", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with mixed slashes", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "..\\../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject absolute paths", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "/etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject paths with forward slashes", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "blog/../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject paths with backslashes", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "blog\\..\\etc\\passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); + + describe("appsec invalid characters", () => { + it("should reject slugs with null bytes", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "test-post\0" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject slugs with special shell characters", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const maliciousSlugs = [ + "test-post; rm -rf /", + "test-post | cat /etc/passwd", + "test-post && cat /etc/passwd", + "test-post $(cat /etc/passwd)", + "test-post `cat /etc/passwd`", + ]; + + for (const slug of maliciousSlugs) { + const context = createMockContext({ slug }); + const result = await loader(context); + expect(result).toBeUndefined(); + } + }); + + it("should reject slugs with unicode characters that could be dangerous", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "test-post\u202e" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); + + describe("appsec allow list validation", () => { + it("should reject slugs not in the allow list", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const invalidSlugs = ["random-slug", "prompt-testing", "fake-post", ""]; + + for (const slug of invalidSlugs) { + const context = createMockContext({ slug }); + const result = await loader(context); + expect(result).toBeUndefined(); + } + }); + + it("should verify allow list is properly constructed", () => { + const meta = getAllBlogMeta(); + const validSlugSet = new Set(meta.map((post) => post.slug)); + + expect(validSlugSet.has("test-post")).toBe(true); + expect(validSlugSet.has("another-post")).toBe(true); + expect(validSlugSet.size).toBe(2); + }); + }); + + describe("edge cases", () => { + it("should handle empty slug", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should handle very long slug", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const longSlug = "a".repeat(1000); + const context = createMockContext({ slug: longSlug }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should handle slug with only special characters", async () => { + const route = createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ slug: "!!!@@@###$$$" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); +}); + +describe("createContentRouteConfig - docs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return undefined for non-existent doc (fails allow list check)", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/non-existent-doc" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should successfully load a valid doc", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("v1/learn/test-doc", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Test Doc", + description: "A test doc", + }), + }), + ); + + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc" }); + const result = (await loader(context)) as DocContent | undefined; + + expect(result).toBeDefined(); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx.frontmatter?.title).toBe("Test Doc"); + }); + + it("should handle index page (empty _splat)", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("v1/index", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Index Page", + description: "Index page", + }), + }), + ); + + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: undefined }); + const result = (await loader(context)) as DocContent | undefined; + + expect(result).toBeDefined(); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx.frontmatter?.title).toBe("Index Page"); + }); + + it("should handle different versions correctly", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("v2/learn/test-doc", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "V2 Doc", + description: "V2 doc", + }), + }), + ); + + // Create a loader with v2 version - our mock metadata only has v1 routes + const route = createContentRouteConfig("/docs/v2/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v2", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc" }); + const result = await loader(context); + + // Should fail because our mock docsMetadata only has v1 routes + expect(result).toBeUndefined(); + }); + + it("should successfully load a non-versioned doc", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("learn/non-versioned-doc", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Non-Versioned Doc", + description: "A non-versioned doc", + }), + }), + ); + + // Loader without version parameter + const route = createContentRouteConfig("/docs/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/non-versioned-doc" }); + const result = (await loader(context)) as DocContent | undefined; + + expect(result).toBeDefined(); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx.frontmatter?.title).toBe("Non-Versioned Doc"); + }); + + it("should handle non-versioned index page (empty _splat)", async () => { + const customModuleMap = createTestModuleMap(); + customModuleMap.set("index", () => + Promise.resolve({ + mdx: createProcessedMDX(testMDXContent, { + title: "Non-Versioned Index", + description: "Non-versioned index page", + }), + }), + ); + + // Loader without version parameter + const route = createContentRouteConfig("/docs/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + component: DummyComponent, + _testModuleMap: customModuleMap, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: undefined }); + const result = (await loader(context)) as DocContent | undefined; + + expect(result).toBeDefined(); + expect(result?.content).toBe(testMDXContent); + expect(result?.mdx.frontmatter?.title).toBe("Non-Versioned Index"); + }); + + it("should not load versioned docs when version is not specified", async () => { + // Try to access v1 doc without specifying version + const route = createContentRouteConfig("/docs/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc" }); + const result = await loader(context); + + // Should fail because path won't match (docs/learn/test-doc vs docs/v1/learn/test-doc) + expect(result).toBeUndefined(); + }); + + it("should not load non-versioned docs when version is specified", async () => { + // Try to access non-versioned doc with v1 version specified + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/non-versioned-doc" }); + const result = await loader(context); + + // Should fail because path won't match (docs/v1/learn/non-versioned-doc vs docs/learn/non-versioned-doc) + expect(result).toBeUndefined(); + }); + + describe("appsec path traversal attacks", () => { + it("should reject path traversal with ../", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "../../../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with ..\\", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + _splat: "..\\..\\..\\windows\\system32", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with encoded ../", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + _splat: "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with double-encoded ../", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ + _splat: "%252e%252e%252f%252e%252e%252fetc%252fpasswd", + }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject path traversal with mixed slashes", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "..\\../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject absolute paths", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "/etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject paths with forward slashes in _splat", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/../etc/passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject paths with backslashes in _splat", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn\\..\\etc\\passwd" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); + + describe("appsec invalid characters", () => { + it("should reject _splat with null bytes", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc\0" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should reject _splat with special shell characters", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const maliciousSplats = [ + "learn/test-doc; rm -rf /", + "learn/test-doc | cat /etc/passwd", + "learn/test-doc && cat /etc/passwd", + "learn/test-doc $(cat /etc/passwd)", + "learn/test-doc `cat /etc/passwd`", + ]; + + for (const splat of maliciousSplats) { + const context = createMockContext({ _splat: splat }); + const result = await loader(context); + expect(result).toBeUndefined(); + } + }); + + it("should reject _splat with unicode characters that could be dangerous", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc\u202e" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + }); + + describe("appsec allow list validation", () => { + it("should reject _splat not in the allow list", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + // Note: empty string "" is NOT invalid - it triggers index page fallback + const invalidSplats = [ + "random/path", + "learn/fake-doc", + "v1/learn/test-doc", // includes version prefix (shouldn't) + ]; + + for (const splat of invalidSplats) { + const context = createMockContext({ _splat: splat }); + const result = await loader(context); + expect(result).toBeUndefined(); + } + }); + + it("should verify allow list is properly constructed", () => { + const docInfos = getAllDocsMeta(); + const validPathSet = new Set(docInfos.map((doc) => doc.path)); + + // Verify our mock data is in the set (paths with "docs/" prefix) + expect(validPathSet.has("docs/v1/learn/test-doc")).toBe(true); + expect(validPathSet.has("docs/v1/learn/another-doc")).toBe(true); + expect(validPathSet.has("docs/v1/index")).toBe(true); + expect(validPathSet.has("docs/learn/non-versioned-doc")).toBe(true); + expect(validPathSet.has("docs/index")).toBe(true); + expect(validPathSet.size).toBe(5); + }); + }); + + describe("edge cases", () => { + it("should handle very long _splat", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const longSplat = "a".repeat(1000); + const context = createMockContext({ _splat: longSplat }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should handle _splat with only special characters", async () => { + const route = createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "!!!@@@###$$$" }); + const result = await loader(context); + + expect(result).toBeUndefined(); + }); + + it("should handle version mismatch", async () => { + // Try to access v1 doc with v2 loader + const route = createContentRouteConfig("/docs/v2/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v2", + component: DummyComponent, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "learn/test-doc" }); + const result = await loader(context); + + // Should fail because path won't match + expect(result).toBeUndefined(); + }); + }); +}); + +describe("createContentRouteConfig - redirectOnEmpty", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should redirect when _splat is empty and redirectOnEmpty is set", async () => { + const route = createContentRouteConfig("/terms/$", { + getMeta: getAllPolicyMeta, + moduleMap: POLICY_MODULE_MAP, + prefix: "policy", + subdirectory: "terms", + component: DummyComponent, + redirectOnEmptySplat: { to: "/terms/$", params: { _splat: "use" } }, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: undefined }); + + await expect(loader(context)).rejects.toThrow("Redirect"); + }); + + it("should not redirect when _splat has a value", async () => { + const route = createContentRouteConfig("/terms/$", { + getMeta: getAllPolicyMeta, + moduleMap: POLICY_MODULE_MAP, + prefix: "policy", + subdirectory: "terms", + component: DummyComponent, + redirectOnEmptySplat: { to: "/terms/$", params: { _splat: "use" } }, + }); + const loader = getLoader(route); + const context = createMockContext({ _splat: "service" }); + + // Should not throw, just return undefined (no matching metadata in mock) + const result = await loader(context); + expect(result).toBeUndefined(); + }); +}); diff --git a/cloud/app/lib/content/route-config.tsx b/cloud/app/lib/content/route-config.tsx new file mode 100644 index 0000000000..8d511f6371 --- /dev/null +++ b/cloud/app/lib/content/route-config.tsx @@ -0,0 +1,363 @@ +import type React from "react"; +import { redirect, useLoaderData } from "@tanstack/react-router"; +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 =========== */ + +/** + * Options for creating a content route. + */ +export interface ContentRouteOptions { + /** Function to get all metadata for this content type */ + getMeta: () => TMeta[]; + /** Module map for this content type */ + moduleMap: ModuleMap; + /** Prefix for metadata paths (e.g., "blog", "docs", "policy") */ + prefix: string; + /** Version segment for versioned content (e.g., "v1") */ + version?: string; + /** Subdirectory within the content type (e.g., "terms" for policy/terms/) */ + subdirectory?: string; + /** Fixed path for single-file content (e.g., "privacy" for policy/privacy) */ + fixedPath?: string; + /** The page component to render when content is loaded */ + component: React.ComponentType<{ content: Content }>; + /** 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 =========== */ + +/** + * Create route configuration for a content route. + * + * Use with TanStack Router's `createFileRoute`: + * + * @example + * ```typescript + * // terms.$.tsx - Subdirectory with redirect on empty splat + * export const Route = createFileRoute("/terms/$")( + * createContentRouteConfig("/terms/$", { + * getMeta: getAllPolicyMeta, + * moduleMap: POLICY_MODULE_MAP, + * prefix: "policy", + * subdirectory: "terms", + * component: PolicyPage, + * redirectOnEmptySplat: { to: "/terms/$", params: { _splat: "use" } }, + * }) + * ); + * ``` + */ +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 + const contentComponent = createContentComponent(options.component, path); + + return { + ssr: false as const, + + head: createContentHead({ + allMetas, + ogType: options.ogType, + robots: options.robots, + getImagePath: options.getImagePath, + }), + + loader: async (context: { + params: Record; + }): Promise | undefined> => { + // Handle redirectOnEmpty for splat routes + if ( + options.redirectOnEmptySplat && + !(context.params as { _splat?: string })._splat + ) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ + to: options.redirectOnEmptySplat.to, + params: options.redirectOnEmptySplat.params, + replace: true, + }); + } + + // Build metadata path from route params and options + const metaPath = buildMetaPath(context.params, options); + + // Find metadata (with universal /index fallback) + let meta = allMetas.find((m) => m.path === metaPath); + if (!meta) { + meta = allMetas.find((m) => m.path === `${metaPath}/index`); + } + + if (!meta) { + return undefined; + } + + // Get module key by stripping the content type prefix + const moduleKey = meta.path.startsWith(`${options.prefix}/`) + ? meta.path.slice(options.prefix.length + 1) + : meta.path; + + const moduleLoader = moduleMap.get(moduleKey); + if (!moduleLoader) { + return undefined; + } + + const module = await moduleLoader(); + return { + meta, + content: module.mdx.content, + mdx: module.mdx, + } as Content; + }, + + 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) { + throw new Error(`Content meta data not found for route: ${route}`); + } + + // 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. + */ +function buildMetaPath( + params: Record, + options: ContentRouteOptions, +): string { + // For fixed paths (like privacy), ignore params + if (options.fixedPath) { + let path = options.prefix; + if (options.subdirectory) { + path += `/${options.subdirectory}`; + } + return `${path}/${options.fixedPath}`; + } + + // Extract path suffix from params + const pathSuffix = params._splat ?? params.slug ?? ""; + + // Build the full metadata path + let metaPath = options.prefix; + if (options.version) { + metaPath += `/${options.version}`; + } + if (options.subdirectory) { + metaPath += `/${options.subdirectory}`; + } + if (pathSuffix) { + metaPath += `/${pathSuffix}`; + } + + return metaPath; +} + +/** + * Create a component that loads content and renders the page component. + */ +function createContentComponent( + PageComponent: React.ComponentType<{ content: Content }>, + path: string, +) { + return function ContentRouteComponent() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const content: Content = useLoaderData({ from: path as any }); + if (!content) { + return ; + } + return ; + }; +} 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.$slug.tsx b/cloud/app/routes/blog.$slug.tsx index ecaed325a1..ad18f69b60 100644 --- a/cloud/app/routes/blog.$slug.tsx +++ b/cloud/app/routes/blog.$slug.tsx @@ -1,33 +1,16 @@ import { createFileRoute } from "@tanstack/react-router"; -import { NotFound } from "@/app/components/not-found"; -import { blogPostContentLoader } from "@/app/lib/content/content-loader"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; +import { + BLOG_MODULE_MAP, + getAllBlogMeta, +} from "@/app/lib/content/virtual-module"; import { BlogPostPage } from "@/app/components/blog-post-page"; -export const Route = createFileRoute("/blog/$slug")({ - ssr: false, - head: (ctx) => { - // todo(sebastian): simplify and add other SEO metadata - const meta = ctx.loaderData?.meta; - if (!meta) { - return {}; - } - return { - meta: [ - { title: meta.title }, - { name: "description", content: meta.description }, - ], - }; - }, - loader: blogPostContentLoader(), - component: BlogPost, -}); - -function BlogPost() { - const post = Route.useLoaderData(); - - if (!post) { - return ; - } - - return ; -} +export const Route = createFileRoute("/blog/$slug")( + createContentRouteConfig("/blog/$slug", { + getMeta: getAllBlogMeta, + moduleMap: BLOG_MODULE_MAP, + prefix: "blog", + component: BlogPostPage, + }), +); 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/docs.v1.$.tsx b/cloud/app/routes/docs.v1.$.tsx index 2ce603b8d2..4736a4a4e0 100644 --- a/cloud/app/routes/docs.v1.$.tsx +++ b/cloud/app/routes/docs.v1.$.tsx @@ -1,39 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; import { - docsContentLoader, - type DocsLoaderData, -} from "@/app/lib/content/content-loader"; + DOCS_MODULE_MAP, + getAllDocsMeta, +} from "@/app/lib/content/virtual-module"; import DocsPage from "@/app/components/docs-page"; -import { NotFound } from "@/app/components/not-found"; -export const Route = createFileRoute("/docs/v1/$")({ - ssr: false, - head: (ctx) => { - // todo(sebastian): simplify and add other SEO metadata - const meta = ctx.loaderData?.meta; - if (!meta) { - return { - meta: [ - { title: "Loading..." }, - { name: "description", content: "Loading documentation content" }, - ], - }; - } - return { - meta: [ - { title: meta.title }, - { name: "description", content: meta.description }, - ], - }; - }, - loader: docsContentLoader("v1"), - component: Document, -}); - -function Document() { - const doc: DocsLoaderData = Route.useLoaderData(); - if (!doc) { - return ; - } - return ; -} +export const Route = createFileRoute("/docs/v1/$")( + createContentRouteConfig("/docs/v1/$", { + getMeta: getAllDocsMeta, + moduleMap: DOCS_MODULE_MAP, + prefix: "docs", + version: "v1", + component: DocsPage, + }), +); 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: () => , }); diff --git a/cloud/app/routes/privacy.tsx b/cloud/app/routes/privacy.tsx index 6391c99129..7b0c88176a 100644 --- a/cloud/app/routes/privacy.tsx +++ b/cloud/app/routes/privacy.tsx @@ -1,41 +1,17 @@ -import { createFileRoute, type LoaderFnContext } from "@tanstack/react-router"; -import { NotFound } from "@/app/components/not-found"; +import { createFileRoute } from "@tanstack/react-router"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; import { - privacyContentLoader, - type PolicyLoaderData, -} from "@/app/lib/content/content-loader"; + POLICY_MODULE_MAP, + getAllPolicyMeta, +} from "@/app/lib/content/virtual-module"; import PolicyPage from "@/app/components/policy-page"; -export const Route = createFileRoute("/privacy")({ - ssr: false, - head: (ctx) => { - // todo(sebastian): simplify and add other SEO metadata - const meta = ctx.loaderData?.meta; - if (!meta) { - return { - meta: [ - { title: "Loading..." }, - { name: "description", content: "Loading privacy content" }, - ], - }; - } - return { - meta: [ - { title: meta.title }, - { name: "description", content: meta.description }, - ], - }; - }, - loader: async ( - ctx: LoaderFnContext>, - ): Promise => { - return privacyContentLoader()(ctx); - }, - component: () => { - const content: PolicyLoaderData = Route.useLoaderData(); - if (!content) { - return ; - } - return ; - }, -}); +export const Route = createFileRoute("/privacy")( + createContentRouteConfig("/privacy", { + getMeta: getAllPolicyMeta, + moduleMap: POLICY_MODULE_MAP, + prefix: "policy", + fixedPath: "privacy", + component: PolicyPage, + }), +); diff --git a/cloud/app/routes/terms.$.tsx b/cloud/app/routes/terms.$.tsx index 80e2c33265..b398275f7a 100644 --- a/cloud/app/routes/terms.$.tsx +++ b/cloud/app/routes/terms.$.tsx @@ -1,56 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { createContentRouteConfig } from "@/app/lib/content/route-config"; import { - createFileRoute, - redirect, - type LoaderFnContext, -} from "@tanstack/react-router"; -import { NotFound } from "@/app/components/not-found"; -import { - termsContentLoader, - type PolicyLoaderData, -} from "@/app/lib/content/content-loader"; + POLICY_MODULE_MAP, + getAllPolicyMeta, +} from "@/app/lib/content/virtual-module"; import PolicyPage from "@/app/components/policy-page"; -export const Route = createFileRoute("/terms/$")({ - ssr: false, - head: (ctx) => { - // todo(sebastian): simplify and add other SEO metadata - const meta = ctx.loaderData?.meta; - if (!meta) { - return { - meta: [ - { title: "Loading..." }, - { name: "description", content: "Loading terms content" }, - ], - }; - } - return { - meta: [ - { title: meta.title }, - { name: "description", content: meta.description }, - ], - }; - }, - loader: async ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ctx: LoaderFnContext, - ): Promise => { - // Redirect on index case (no splat) - if (!ctx.params._splat) { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect({ - to: "/terms/$", - params: { _splat: "use" }, - replace: true, - }); - } - return termsContentLoader()(ctx); - }, - component: () => { - const content: PolicyLoaderData = Route.useLoaderData(); - if (!content) { - return ; - } - // todo(sebastian): Port actual page - return ; - }, -}); +export const Route = createFileRoute("/terms/$")( + createContentRouteConfig("/terms/$", { + getMeta: getAllPolicyMeta, + moduleMap: POLICY_MODULE_MAP, + prefix: "policy", + fixedPath: "terms", + component: PolicyPage, + redirectOnEmptySplat: { to: "/terms/$", params: { _splat: "use" } }, + }), +);