diff --git a/cloud/app/components/blocks/docs/main-content.tsx b/cloud/app/components/blocks/docs/main-content.tsx new file mode 100644 index 0000000000..ac92363662 --- /dev/null +++ b/cloud/app/components/blocks/docs/main-content.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import LoadingContent from "@/app/components/blocks/loading-content"; +// import PagefindMeta from "@/app/components/blocks/pagefind-meta"; +import { MDXRenderer } from "@/app/components/mdx/renderer"; +import { type DocContent } from "@/app/lib/content/types"; + +interface MainContentProps { + document: DocContent; +} + +/** + * MainContent - Main document content area + * + * Displays the document title, description, and rendered MDX content + */ +const MainContent: React.FC = ({ document }) => { + // const path = document.meta.path; + // const pieces = path.split("/"); + // const section = pieces.slice(0, 3).join("/"); + return ( +
+
+
+ {document.mdx ? ( + + ) : ( + + )} + {/* {document.mdx ? ( + + + + ) : ( + + )} */} +
+
+
+ ); +}; + +export default MainContent; diff --git a/cloud/app/components/blocks/docs/sidebar.tsx b/cloud/app/components/blocks/docs/sidebar.tsx new file mode 100644 index 0000000000..f59ae6e48d --- /dev/null +++ b/cloud/app/components/blocks/docs/sidebar.tsx @@ -0,0 +1,198 @@ +import { docRegistry } from "@/app/lib/content/doc-registry"; +import type { DocSpec, SectionSpec } from "@/app/lib/content/spec"; +import { type Provider } from "@/app/components/blocks/model-provider-provider"; +import Sidebar from "@/app/components/page-sidebar"; +import type { + SidebarConfig, + SidebarItem, + SidebarGroup, + SidebarSection, +} from "@/app/components/page-sidebar"; +import { docsSpec } from "@/content/docs/_meta"; + +interface DocsSidebarProps { + selectedProvider?: Provider; + onProviderChange?: (provider: Provider) => void; +} + +// No product selector needed in sidebar - now in header + +/** + * Helper to convert the spec metadata to the sidebar format + */ +function createSidebarConfig(): SidebarConfig { + // Get all DocInfo objects + const allDocInfo = docRegistry.getAllDocs(); + + // Create a map from slug pattern to routePath for quick lookup + // Key format: version/section/slug or version/slug for root items + const slugToRoutePathMap: Map = new Map(); + + allDocInfo.forEach((doc) => { + // Extract the slug pattern from the path + const keyPath = doc.path; + slugToRoutePathMap.set(keyPath, doc.routePath); + }); + + // Get all sections and order them appropriately + const allSections = [...docsSpec.sections]; + + // Find index section to ensure it appears first + const defaultIndex = allSections.findIndex((s) => s.slug === "index"); + if (defaultIndex > 0) { + // Move index section to the front + const defaultSection = allSections.splice(defaultIndex, 1)[0]; + allSections.unshift(defaultSection); + } + + // Convert doc specs to sidebar items + function convertDocToSidebarItem( + doc: DocSpec, + parentPath: string, + ): SidebarItem { + // Construct the logical path for this item (used to look up routePath) + const itemPath = parentPath ? `${parentPath}/${doc.slug}` : doc.slug; + + // Look up the routePath from DocInfo if available + const routePath = slugToRoutePathMap.get(itemPath); + + // Determine hasContent: explicit value from doc, or default based on children + const hasContent = doc.hasContent ?? !doc.children; + + const item: SidebarItem = { + slug: doc.slug, + label: doc.label, + hasContent, + }; + + // Add routePath if we found a match + if (routePath) { + item.routePath = routePath; + } + + // Process children if any + if (doc.children && doc.children.length > 0) { + item.items = {}; + + doc.children.forEach((childDoc) => { + const childItem = convertDocToSidebarItem(childDoc, itemPath); + if (item.items) { + item.items[childDoc.slug] = childItem; + } + }); + } + + return item; + } + + // Helper to build path prefix for a section (matches logic in spec.ts getDocsFromSpec) + function getSectionPathPrefix(section: SectionSpec): string { + const versionPrefix = section.version || ""; + const isDefaultSection = section.slug === "index"; + const sectionSlug = isDefaultSection ? "" : section.slug; + + if (versionPrefix && sectionSlug) { + return `${versionPrefix}/${sectionSlug}`; + } else if (versionPrefix) { + return versionPrefix; + } else if (sectionSlug) { + return sectionSlug; + } + return ""; + } + + // Create sidebar sections from spec sections + const sidebarSections: SidebarSection[] = allSections.map((section) => { + const pathPrefix = getSectionPathPrefix(section); + + // Create basePath for URL routing + const basePath = pathPrefix ? `/docs/${pathPrefix}` : "/docs"; + + // Process direct items (those without children) and create groups for top-level folders + const items: Record = {}; + const groups: Record = {}; + + section.children.forEach((child) => { + const hasContent = child.hasContent ?? !child.children; + + if (hasContent) { + // This item has content, add it to items (even if it also has children) + items[child.slug] = convertDocToSidebarItem(child, pathPrefix); + } else { + // This is a pure folder (no content), add it as a group + const groupItems: Record = {}; + + // Get path for this group's children + const groupPathPrefix = pathPrefix + ? `${pathPrefix}/${child.slug}` + : child.slug; + + // Process all items in this group + if (child.children) { + child.children.forEach((grandchild) => { + // Convert the grandchild and its descendants to sidebar items + const sidebarItem = convertDocToSidebarItem( + grandchild, + groupPathPrefix, + ); + groupItems[grandchild.slug] = sidebarItem; + }); + } + + // Add the group + groups[child.slug] = { + slug: child.slug, + label: child.label, + items: groupItems, + }; + } + }); + + return { + slug: section.slug, + label: section.label, + basePath, + items, + groups: Object.keys(groups).length > 0 ? groups : undefined, + }; + }); + + // Inject LLM Documentation section + // Use the first section's version for the LLM docs path + // const firstVersion = allSections[0]?.version || ""; + // const llmBasePath = firstVersion ? `/docs/${firstVersion}` : "/docs"; + + // todo(sebastian): add LLM section back in + // const llmItem: SidebarItem = { + // slug: "llms", + // label: "LLMs Text", + // routePath: `${llmBasePath}/llms-full`, + // hasContent: true, + // }; + // const llmSection: SidebarSection = { + // slug: "llms", + // label: "LLMs Text", + // basePath: `${llmBasePath}/llms-full`, + // items: { llms: llmItem }, + // }; + + // Add the LLM section to the end + // sidebarSections.push(llmSection); + + // Return the complete sidebar config + return { + label: "Documentation", + sections: sidebarSections, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const DocsSidebar = (_props: DocsSidebarProps) => { + // Create sidebar configuration + const sidebarConfig = createSidebarConfig(); + + // No header content needed since product links are in the main header now + return ; +}; + +export default DocsSidebar; diff --git a/cloud/app/components/docs-page.tsx b/cloud/app/components/docs-page.tsx new file mode 100644 index 0000000000..7840f31734 --- /dev/null +++ b/cloud/app/components/docs-page.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import PageLayout from "@/app/components/page-layout"; +import LoadingContent from "@/app/components/blocks/loading-content"; +import { ProviderContextProvider } from "@/app/components/blocks/model-provider-provider"; +// import TocSidebar from "@/app/components/toc-sidebar"; +import MainContent from "@/app/components/blocks/docs/main-content"; +import DocsSidebar from "@/app/components/blocks/docs/sidebar"; +import type { DocContent } from "@/app/lib/content/types"; + +type DocsPageProps = { + document?: DocContent; + isLoading?: boolean; +}; + +/** + * 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 }) => { + return ( + <> + + + + + + + + {isLoading ? ( + + ) : ( + document && + )} + + + + Sidebar content + {/* {isLoading ? ( +
+
+
+ ) : ( + document && + )} */} +
+
+
+ + ); +}; + +export default DocsPage; diff --git a/cloud/app/components/home-page.tsx b/cloud/app/components/home-page.tsx index 2f7e2919f4..3c6530c46a 100644 --- a/cloud/app/components/home-page.tsx +++ b/cloud/app/components/home-page.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "@/app/lib/utils"; import { useSunsetTime } from "@/app/hooks/sunset-time"; import { useGradientFadeOnScroll } from "@/app/hooks/gradient-fade-scroll"; -import { BookOpen, ChevronDown, ChevronUp, Rocket, Users } from "lucide-react"; +import { BookOpen, ChevronDown, ChevronUp, Users } from "lucide-react"; import { ButtonLink } from "@/app/components/ui/button-link"; import homeStyles from "@/app/components/home-page.module.css"; import { ResponsiveTextBlock } from "@/app/components/blocks/responsive-text-block"; @@ -149,13 +149,13 @@ function HeroBlock({ onScrollDown, showScrollButton }: HeroBlockProps) {
- ; children: React.ReactNode; }) => { - const activeClass = `bg-button-primary text-white font-medium`; + const activeClass = `bg-primary text-white font-medium`; const inactiveClass = `text-muted-foreground hover:bg-muted hover:text-muted-foreground`; return ( diff --git a/cloud/app/lib/content/content-loader.test.ts b/cloud/app/lib/content/content-loader.test.ts new file mode 100644 index 0000000000..0c88de3a53 --- /dev/null +++ b/cloud/app/lib/content/content-loader.test.ts @@ -0,0 +1,664 @@ +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 new file mode 100644 index 0000000000..31d6a932a8 --- /dev/null +++ b/cloud/app/lib/content/content-loader.ts @@ -0,0 +1,146 @@ +import type { LoaderFnContext, AnyRoute } from "@tanstack/react-router"; +import type { + BlogContent, + BlogMeta, + DocContent, + DocMeta, +} from "@/app/lib/content/types"; +import type { ProcessedMDX } from "@/app/lib/mdx/types"; +import { + BLOG_MODULE_MAP, + DOCS_MODULE_MAP, + getAllBlogMeta, + getAllDocsMeta, + 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 }), + }, + ); +} diff --git a/cloud/app/lib/content/meta.test.ts b/cloud/app/lib/content/meta.test.ts deleted file mode 100644 index ae06135d28..0000000000 --- a/cloud/app/lib/content/meta.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { blogPostContentLoader, getAllBlogMeta } from "./meta"; -import type { MDXImporter } from "./meta"; -import type { LoaderFnContext, AnyRoute } from "@tanstack/react-router"; -import { createProcessedMDX } from "./mdx-compile"; - -// Mock the virtual module -vi.mock("virtual:content-meta", () => ({ - blogPosts: [ - { - 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", - }, - ], -})); - -// Test MDX content fixture -const testMDXContent = "# Test Content\n\nThis is test content."; - -// Create a test MDX importer that returns compiled MDX for known slugs -function createTestImporter( - slugToContent: Record< - string, - { content: string; frontmatter: Record } - >, -): MDXImporter { - return (slug) => { - const entry = slugToContent[slug]; - if (!entry) { - return Promise.resolve(undefined); - } - return Promise.resolve({ - mdx: createProcessedMDX(entry.content, entry.frontmatter), - }); - }; -} - -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 context = createMockContext("non-existent-post"); - const result = await blogPostContentLoader(context); - - // Should return undefined because it's not in the allow list - expect(result).toBeUndefined(); - }); - - it("should successfully load a valid blog post", async () => { - const testImporter = createTestImporter({ - "test-post": { - content: testMDXContent, - frontmatter: { - title: "Test Post", - description: "A test blog post", - date: "2025-01-01", - author: "Test Author", - }, - }, - }); - - const context = createMockContext("test-post"); - const result = await blogPostContentLoader(context, testImporter); - - 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 context = createMockContext("../../../etc/passwd"); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with ..\\", async () => { - const context = createMockContext("..\\..\\..\\windows\\system32"); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with encoded ../", async () => { - const context = createMockContext( - "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", - ); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject path traversal with mixed slashes", async () => { - const context = createMockContext("..\\../etc/passwd"); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject absolute paths", async () => { - const context = createMockContext("/etc/passwd"); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with forward slashes", async () => { - const context = createMockContext("blog/../etc/passwd"); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject paths with backslashes", async () => { - const context = createMockContext("blog\\..\\etc\\passwd"); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec invalid characters", () => { - it("should reject slugs with null bytes", async () => { - const context = createMockContext("test-post\0"); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - - it("should reject slugs with special shell characters", async () => { - 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 blogPostContentLoader(context); - expect(result).toBeUndefined(); - } - }); - - it("should reject slugs with unicode characters that could be dangerous", async () => { - const context = createMockContext("test-post\u202e"); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - }); - - describe("appsec allow list validation", () => { - it("should reject slugs not in the allow list", async () => { - 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 blogPostContentLoader(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 context = createMockContext(""); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle very long slug", async () => { - const longSlug = "a".repeat(1000); - const context = createMockContext(longSlug); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - - it("should handle slug with only special characters", async () => { - const context = createMockContext("!!!@@@###$$$"); - const result = await blogPostContentLoader(context); - - expect(result).toBeUndefined(); - }); - }); -}); diff --git a/cloud/app/lib/content/meta.ts b/cloud/app/lib/content/meta.ts deleted file mode 100644 index d85479d5d0..0000000000 --- a/cloud/app/lib/content/meta.ts +++ /dev/null @@ -1,61 +0,0 @@ -// @ts-expect-error - virtual module resolved by vite plugin -import { blogPosts } from "virtual:content-meta"; -import type { LoaderFnContext, AnyRoute } from "@tanstack/react-router"; -import type { BlogContent, BlogMeta } from "@/app/lib/content/types"; -import type { ProcessedMDX } from "@/app/lib/mdx/types"; - -export function getAllBlogMeta(): BlogMeta[] { - return blogPosts as BlogMeta[]; -} - -/** - * Function type for importing MDX modules. - * Used for dependency injection in tests. - */ -export type MDXImporter = ( - slug: string, -) => Promise<{ mdx: ProcessedMDX } | undefined>; - -/** - * Default MDX importer using dynamic imports. - * This is the production implementation. - */ -const defaultMDXImporter: MDXImporter = async (slug) => { - return (await import(`@/content/blog/${slug}.mdx`)) as { mdx: ProcessedMDX }; -}; - -// todo(sebastian): concrete prior to generic version -export async function blogPostContentLoader( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: LoaderFnContext, - /** Optional MDX importer for testing */ - importMDX: MDXImporter = defaultMDXImporter, -): Promise { - const { slug } = context.params; - - const blogPosts = getAllBlogMeta(); - - // Validate slug to prevent path traversal attacks - const meta = blogPosts.find((post) => post.slug === slug); - if (!meta) { - return undefined; - } - - try { - const module = await importMDX(slug); - if (!module) { - return undefined; - } - const { mdx } = module; - const { content } = mdx; - - return { - meta, - content, - mdx, - }; - } catch (err) { - console.warn(`Error loading blog post ${slug}:`, err); - return undefined; - } -} diff --git a/cloud/app/lib/content/spec.test.ts b/cloud/app/lib/content/spec.test.ts index bf9fa02d9c..1e10011ba1 100644 --- a/cloud/app/lib/content/spec.test.ts +++ b/cloud/app/lib/content/spec.test.ts @@ -161,7 +161,7 @@ describe("getDocsFromSpec", () => { expect(result[0]).toMatchObject({ label: "Overview", slug: "overview", - path: "api/overview", + path: "docs/api/overview", routePath: "/docs/api/overview", type: "docs", }); @@ -185,7 +185,7 @@ describe("getDocsFromSpec", () => { expect(result[0]).toMatchObject({ label: "Overview", slug: "overview", - path: "v1/api/overview", + path: "docs/v1/api/overview", routePath: "/docs/v1/api/overview", type: "docs", }); @@ -209,7 +209,7 @@ describe("getDocsFromSpec", () => { expect(result[0]).toMatchObject({ label: "Getting Started", slug: "getting-started", - path: "v1/getting-started", + path: "docs/v1/getting-started", routePath: "/docs/v1/getting-started", type: "docs", }); @@ -232,7 +232,7 @@ describe("getDocsFromSpec", () => { expect(result[0]).toMatchObject({ label: "Getting Started", slug: "getting-started", - path: "getting-started", + path: "docs/getting-started", routePath: "/docs/getting-started", type: "docs", }); @@ -266,19 +266,19 @@ describe("getDocsFromSpec", () => { expect(result[0]).toMatchObject({ label: "Getting Started", slug: "getting-started", - path: "v1/getting-started", + path: "docs/v1/getting-started", routePath: "/docs/v1/getting-started", }); expect(result[1]).toMatchObject({ label: "Why Mirascope?", slug: "why", - path: "v1/getting-started/why", + path: "docs/v1/getting-started/why", routePath: "/docs/v1/getting-started/why", }); expect(result[2]).toMatchObject({ label: "Help", slug: "help", - path: "v1/getting-started/help", + path: "docs/v1/getting-started/help", routePath: "/docs/v1/getting-started/help", }); }); @@ -307,13 +307,13 @@ describe("getDocsFromSpec", () => { expect(result[0]).toMatchObject({ label: "Welcome", slug: "welcome", - path: "v1/welcome", + path: "docs/v1/welcome", routePath: "/docs/v1/welcome", }); expect(result[1]).toMatchObject({ label: "Overview", slug: "overview", - path: "v1/api/overview", + path: "docs/v1/api/overview", routePath: "/docs/v1/api/overview", }); }); @@ -342,13 +342,13 @@ describe("getDocsFromSpec", () => { expect(result[0]).toMatchObject({ label: "Getting Started", slug: "getting-started", - path: "getting-started", + path: "docs/getting-started", routePath: "/docs/getting-started", }); expect(result[1]).toMatchObject({ label: "Overview", slug: "overview", - path: "v1/api/overview", + path: "docs/v1/api/overview", routePath: "/docs/v1/api/overview", }); }); @@ -371,7 +371,7 @@ describe("getDocsFromSpec", () => { expect(result[0]).toMatchObject({ label: "Welcome", slug: "index", - path: "v1/index", + path: "docs/v1/index", routePath: "/docs/v1", // Index pages use trailing slash (no /index in route) }); }); @@ -401,7 +401,7 @@ describe("getDocsFromSpec", () => { expect(result[0]).toMatchObject({ label: "Child", slug: "child", - path: "v1/folder/child", + path: "docs/v1/folder/child", routePath: "/docs/v1/folder/child", }); }); diff --git a/cloud/app/lib/content/spec.ts b/cloud/app/lib/content/spec.ts index 3d1d7973de..d7da64d011 100644 --- a/cloud/app/lib/content/spec.ts +++ b/cloud/app/lib/content/spec.ts @@ -77,8 +77,9 @@ export function processDocSpec( // Calculate the current weight by multiplying parent weight with this item's weight const currentWeight = parentWeight * (docSpec.weight || 1.0); - // Simple path construction for content loading - always include the slug - const path = pathPrefix ? `${pathPrefix}/${slug}` : slug; + // Path construction for content loading - always include the slug + const relativePath = pathPrefix ? `${pathPrefix}/${slug}` : slug; + const docPath = `docs/${relativePath}`; // For URL route path: handle index pages with trailing slashes let routePath = pathPrefix ? `/docs/${pathPrefix}` : "/docs"; @@ -92,7 +93,7 @@ export function processDocSpec( result.push({ label: docSpec.label, slug: docSpec.slug, - path, + path: docPath, routePath, type: "docs", searchWeight: currentWeight, @@ -102,7 +103,7 @@ export function processDocSpec( // Process children recursively with updated section path and weight if (docSpec.children && docSpec.children.length > 0) { docSpec.children.forEach((childSpec) => { - const childItems = processDocSpec(childSpec, path, currentWeight); + const childItems = processDocSpec(childSpec, relativePath, currentWeight); result.push(...childItems); }); } diff --git a/cloud/app/routeTree.gen.ts b/cloud/app/routeTree.gen.ts index 92c2eeafb5..6ae29da63d 100644 --- a/cloud/app/routeTree.gen.ts +++ b/cloud/app/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as AuthMeRouteImport } from './routes/auth/me' import { Route as AuthGoogleRouteImport } from './routes/auth/google' import { Route as AuthGithubRouteImport } from './routes/auth/github' import { Route as DocsV1PlaceholderRouteImport } from './routes/docs.v1.placeholder' +import { Route as DocsV1SplatRouteImport } from './routes/docs.v1.$' import { Route as AuthGoogleProxyCallbackRouteImport } from './routes/auth/google.proxy-callback' import { Route as AuthGoogleCallbackRouteImport } from './routes/auth/google.callback' import { Route as AuthGithubProxyCallbackRouteImport } from './routes/auth/github.proxy-callback' @@ -121,6 +122,11 @@ const DocsV1PlaceholderRoute = DocsV1PlaceholderRouteImport.update({ path: '/v1/placeholder', getParentRoute: () => DocsRoute, } as any) +const DocsV1SplatRoute = DocsV1SplatRouteImport.update({ + id: '/v1/$', + path: '/v1/$', + getParentRoute: () => DocsRoute, +} as any) const AuthGoogleProxyCallbackRoute = AuthGoogleProxyCallbackRouteImport.update({ id: '/proxy-callback', path: '/proxy-callback', @@ -192,6 +198,7 @@ export interface FileRoutesByFullPath { '/auth/github/proxy-callback': typeof AuthGithubProxyCallbackRoute '/auth/google/callback': typeof AuthGoogleCallbackRoute '/auth/google/proxy-callback': typeof AuthGoogleProxyCallbackRoute + '/docs/v1/$': typeof DocsV1SplatRoute '/docs/v1/placeholder': typeof DocsV1PlaceholderRoute '/router/v0/$provider/$': typeof RouterV0ProviderSplatRoute } @@ -219,6 +226,7 @@ export interface FileRoutesByTo { '/auth/github/proxy-callback': typeof AuthGithubProxyCallbackRoute '/auth/google/callback': typeof AuthGoogleCallbackRoute '/auth/google/proxy-callback': typeof AuthGoogleProxyCallbackRoute + '/docs/v1/$': typeof DocsV1SplatRoute '/docs/v1/placeholder': typeof DocsV1PlaceholderRoute '/router/v0/$provider/$': typeof RouterV0ProviderSplatRoute } @@ -248,6 +256,7 @@ export interface FileRoutesById { '/auth/github/proxy-callback': typeof AuthGithubProxyCallbackRoute '/auth/google/callback': typeof AuthGoogleCallbackRoute '/auth/google/proxy-callback': typeof AuthGoogleProxyCallbackRoute + '/docs/v1/$': typeof DocsV1SplatRoute '/docs/v1/placeholder': typeof DocsV1PlaceholderRoute '/router/v0/$provider/$': typeof RouterV0ProviderSplatRoute } @@ -278,6 +287,7 @@ export interface FileRouteTypes { | '/auth/github/proxy-callback' | '/auth/google/callback' | '/auth/google/proxy-callback' + | '/docs/v1/$' | '/docs/v1/placeholder' | '/router/v0/$provider/$' fileRoutesByTo: FileRoutesByTo @@ -305,6 +315,7 @@ export interface FileRouteTypes { | '/auth/github/proxy-callback' | '/auth/google/callback' | '/auth/google/proxy-callback' + | '/docs/v1/$' | '/docs/v1/placeholder' | '/router/v0/$provider/$' id: @@ -333,6 +344,7 @@ export interface FileRouteTypes { | '/auth/github/proxy-callback' | '/auth/google/callback' | '/auth/google/proxy-callback' + | '/docs/v1/$' | '/docs/v1/placeholder' | '/router/v0/$provider/$' fileRoutesById: FileRoutesById @@ -480,6 +492,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsV1PlaceholderRouteImport parentRoute: typeof DocsRoute } + '/docs/v1/$': { + id: '/docs/v1/$' + path: '/v1/$' + fullPath: '/docs/v1/$' + preLoaderRoute: typeof DocsV1SplatRouteImport + parentRoute: typeof DocsRoute + } '/auth/google/proxy-callback': { id: '/auth/google/proxy-callback' path: '/proxy-callback' @@ -559,10 +578,12 @@ const BlogRouteChildren: BlogRouteChildren = { const BlogRouteWithChildren = BlogRoute._addFileChildren(BlogRouteChildren) interface DocsRouteChildren { + DocsV1SplatRoute: typeof DocsV1SplatRoute DocsV1PlaceholderRoute: typeof DocsV1PlaceholderRoute } const DocsRouteChildren: DocsRouteChildren = { + DocsV1SplatRoute: DocsV1SplatRoute, DocsV1PlaceholderRoute: DocsV1PlaceholderRoute, } diff --git a/cloud/app/routes/blog.$slug.tsx b/cloud/app/routes/blog.$slug.tsx index 171591380b..ecaed325a1 100644 --- a/cloud/app/routes/blog.$slug.tsx +++ b/cloud/app/routes/blog.$slug.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { NotFound } from "@/app/components/not-found"; -import { blogPostContentLoader } from "@/app/lib/content/meta"; +import { blogPostContentLoader } from "@/app/lib/content/content-loader"; import { BlogPostPage } from "@/app/components/blog-post-page"; export const Route = createFileRoute("/blog/$slug")({ @@ -18,7 +18,7 @@ export const Route = createFileRoute("/blog/$slug")({ ], }; }, - loader: blogPostContentLoader, + loader: blogPostContentLoader(), component: BlogPost, }); diff --git a/cloud/app/routes/blog.index.tsx b/cloud/app/routes/blog.index.tsx index d09e860102..791fb51ce6 100644 --- a/cloud/app/routes/blog.index.tsx +++ b/cloud/app/routes/blog.index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { getAllBlogMeta } from "@/app/lib/content/meta"; -import { BlogPage } from "../components/blog-page"; +import { getAllBlogMeta } from "@/app/lib/content/virtual-module"; +import { BlogPage } from "@/app/components/blog-page"; export const Route = createFileRoute("/blog/")({ // todo(sebastian): simplify and add other SEO metadata diff --git a/cloud/app/routes/docs.v1.$.tsx b/cloud/app/routes/docs.v1.$.tsx new file mode 100644 index 0000000000..2ce603b8d2 --- /dev/null +++ b/cloud/app/routes/docs.v1.$.tsx @@ -0,0 +1,39 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + docsContentLoader, + type DocsLoaderData, +} from "@/app/lib/content/content-loader"; +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 ; +} diff --git a/cloud/content/docs/_meta.ts b/cloud/content/docs/_meta.ts index 24ef304a65..507c4728a6 100644 --- a/cloud/content/docs/_meta.ts +++ b/cloud/content/docs/_meta.ts @@ -8,7 +8,7 @@ import v1ApiSection from "./v1/api/_meta"; import v1GuidesSection from "./v1/guides/_meta"; export const docsSpec: FullDocsSpec = { - sections: [v1DocsSection, v1ApiSection, v1GuidesSection], + sections: [v1DocsSection, v1GuidesSection, v1ApiSection], }; export const docInfos = getDocsFromSpec(docsSpec); @@ -19,5 +19,6 @@ for (const docInfo of docInfos) { } export function getDocInfoByPath(path: string): DocInfo | undefined { - return pathToDocInfo.get(path); + // DocInfo.path includes "docs/" prefix, but vite plugin passes subpath without prefix + return pathToDocInfo.get(`docs/${path}`) ?? pathToDocInfo.get(path); }