diff --git a/.devcontainer/frontend/devcontainer.json b/.devcontainer/frontend/devcontainer.json
index 072fad3c8bd..3f3c505f36a 100644
--- a/.devcontainer/frontend/devcontainer.json
+++ b/.devcontainer/frontend/devcontainer.json
@@ -11,10 +11,14 @@
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"dsznajder.es7-react-js-snippets",
- "csstools.postcss"
+ "csstools.postcss",
+ "graphql.vscode-graphql",
+ "esbenp.prettier-vscode",
+ "anthropic.claude-code",
+ "openai.chatgpt"
]
}
},
"forwardPorts": [3000],
- "postCreateCommand": "node --version && npm --version"
+ "postCreateCommand": "node --version && npm --version && npm i -g @anthropic-ai/claude-code"
}
diff --git a/.docker/website/dockerfile b/.docker/website/dockerfile
index fb610bafab1..50d34de6361 100644
--- a/.docker/website/dockerfile
+++ b/.docker/website/dockerfile
@@ -5,4 +5,4 @@ RUN rm /etc/nginx/conf.d/default.conf
COPY ./website/config/conf.d /etc/nginx/conf.d
-COPY ./website/public /usr/share/nginx/html
+COPY ./website/out /usr/share/nginx/html
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 439acdfafb2..dac6180b327 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -119,7 +119,7 @@ jobs:
with:
path: |
website/.yarn/cache
- website/.cache/yarn
+ website/.next/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('website/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
@@ -129,7 +129,7 @@ jobs:
working-directory: website
- name: Build Website
- run: yarn build --prefix-paths
+ run: yarn build
working-directory: website
configure:
diff --git a/.github/workflows/publish-website.yml b/.github/workflows/publish-website.yml
index 95ab1b457f2..33e2b50e715 100644
--- a/.github/workflows/publish-website.yml
+++ b/.github/workflows/publish-website.yml
@@ -41,7 +41,7 @@ jobs:
with:
path: |
website/.yarn/cache
- website/.cache/yarn
+ website/.next/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('website/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
@@ -66,7 +66,7 @@ jobs:
working-directory: website
- name: Build Website
- run: yarn build --prefix-paths
+ run: yarn build
working-directory: website
- name: Build WebSite Container
@@ -81,7 +81,7 @@ jobs:
uses: azure/webapps-deploy@v3
with:
app-name: ccc-p-us1-website
- slot-name: "production"
+ slot-name: "next"
publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }}
images: ${{ secrets.CONTAINER_REG_DOMAIN }}/ccc-website-${{ github.ref_name }}:${{ github.run_id }}
diff --git a/AGENTS.md b/AGENTS.md
index c857c231685..c446a0473f9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -75,21 +75,21 @@ src/GreenDonut/ # Data fetching primitives
src/StrawberryShake/ # GraphQL client
src/CookieCrumble/ # Snapshot testing
-website/ # Documentation (Gatsby)
+website/ # Documentation (Next.js)
templates/ # Project templates
.build/ # Build scripts (Nuke)
```
## Tech Stack
-| Component | Technology |
-| --------- | -------------- |
-| Runtime | .NET 8/9/10 |
-| SDK | 10.0.102 |
-| Build | Nuke.Build |
-| Tests | xUnit |
-| Snapshots | CookieCrumble |
-| Docs | Gatsby + React |
+| Component | Technology |
+| --------- | --------------- |
+| Runtime | .NET 8/9/10 |
+| SDK | 10.0.102 |
+| Build | Nuke.Build |
+| Tests | xUnit |
+| Snapshots | CookieCrumble |
+| Docs | Next.js + React |
## Coding Standards
diff --git a/CLAUDE.md b/CLAUDE.md
index 57ea00d1bb0..8c0ecad31d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,7 +70,7 @@ src/
├── CookieCrumble/ # Snapshot testing framework
└── StrawberryShake/ # GraphQL client
-website/ # Gatsby documentation site
+website/ # Next.js documentation site
templates/ # Project templates
.build/ # Nuke build automation
```
@@ -80,7 +80,7 @@ templates/ # Project templates
- **.NET**: SDK 10.0.102 (supports .NET 8, 9, 10)
- **Build System**: Nuke.Build
- **Testing**: xUnit with CookieCrumble for snapshots
-- **Documentation**: Gatsby + React + TypeScript
+- **Documentation**: Next.js + React + TypeScript
## Code Conventions
@@ -121,7 +121,7 @@ dotnet test src/HotChocolate/Core/test/Types.Tests/HotChocolate.Types.Tests.cspr
```bash
cd website
-yarn start # Development server
+yarn dev # Development server
yarn build # Production build
```
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dc48570e5c5..b90ebf7cea0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -69,7 +69,7 @@ After cloning the repository, run `init.sh` or `init.cmd`, which are located in
Other more focused solution files exist if you want to narrow in on a particular part of the platform.
The smaller solution files are great when working with VSCode.
-The documentation is located in the `website` directory and can be started with `yarn start`.
+The documentation is located in the `website` directory and can be started with `yarn dev`.
There are other available commands too. As set up in the [.build](./.build/) directory.
diff --git a/website/.gitignore b/website/.gitignore
index 8a69b1c1435..cfd7376adf7 100644
--- a/website/.gitignore
+++ b/website/.gitignore
@@ -59,10 +59,14 @@ typings/
# dotenv environment variable files
.env*
-# gatsby files
+# gatsby files (legacy)
.cache/
-public
/graphql-types.ts
+# next.js
+.next/
+out/
+next-env.d.ts
+
# Mac files
.DS_Store
diff --git a/website/.yarnrc.yml b/website/.yarnrc.yml
index 702ba5dd308..5b81d0cab0e 100644
--- a/website/.yarnrc.yml
+++ b/website/.yarnrc.yml
@@ -4,4 +4,15 @@ enableGlobalCache: false
nodeLinker: node-modules
+supportedArchitectures:
+ os:
+ - current
+ - linux
+ - darwin
+ - win32
+ cpu:
+ - current
+ - arm64
+ - x64
+
yarnPath: .yarn/releases/yarn-4.8.1.cjs
diff --git a/website/app/blog/[...slug]/page.tsx b/website/app/blog/[...slug]/page.tsx
new file mode 100644
index 00000000000..d5ae3809e8b
--- /dev/null
+++ b/website/app/blog/[...slug]/page.tsx
@@ -0,0 +1,107 @@
+import React from "react";
+
+import {
+ getAllBlogPosts,
+ getBlogPostBySlug,
+ getLatestPostsForNav,
+ getPaginatedPosts,
+ getPostsPerPage,
+} from "@/lib/blog";
+import { compileMdxContent, extractHeadings } from "@/lib/mdx";
+import { createMetadata } from "@/lib/metadata";
+import { BlogPostPage } from "@/lib/blog-post-page";
+import { BlogListPage } from "@/lib/blog-list-page";
+import { notFound } from "next/navigation";
+
+interface PageProps {
+ params: Promise<{ slug: string[] }>;
+}
+
+export async function generateStaticParams() {
+ const posts = getAllBlogPosts();
+ const postsPerPage = getPostsPerPage();
+ const totalPages = Math.ceil(posts.length / postsPerPage);
+
+ const params: { slug: string[] }[] = [];
+
+ // Pagination pages (page 2+)
+ for (let i = 2; i <= totalPages; i++) {
+ params.push({ slug: [String(i)] });
+ }
+
+ // Blog post pages (year/month/day/slug)
+ for (const post of posts) {
+ const parts = post.slug.replace(/^\/blog\//, "").split("/");
+ params.push({ slug: parts });
+ }
+
+ return params;
+}
+
+export async function generateMetadata({ params }: PageProps) {
+ const { slug } = await params;
+
+ // Pagination page
+ if (slug.length === 1 && /^\d+$/.test(slug[0])) {
+ return createMetadata({ title: `Blog - Page ${slug[0]}` });
+ }
+
+ // Blog post (year/month/day/slug)
+ if (slug.length === 4) {
+ const postSlug = `/blog/${slug.join("/")}`;
+ const post = getBlogPostBySlug(postSlug);
+
+ return createMetadata({
+ title: post?.title || "Blog Post",
+ description: post?.description,
+ isArticle: true,
+ imageUrl: post?.featuredImage,
+ });
+ }
+
+ return createMetadata({ title: "Blog" });
+}
+
+export default async function BlogCatchAllPage({ params }: PageProps) {
+ const { slug } = await params;
+
+ // Pagination page (e.g., /blog/2, /blog/3)
+ if (slug.length === 1 && /^\d+$/.test(slug[0])) {
+ const pageNum = parseInt(slug[0], 10);
+ const { posts, totalPages } = getPaginatedPosts(pageNum);
+
+ return (
+
+ );
+ }
+
+ // Blog post page (e.g., /blog/2024/01/15/my-post)
+ if (slug.length === 4) {
+ const postSlug = `/blog/${slug.join("/")}`;
+ const post = getBlogPostBySlug(postSlug);
+
+ if (!post) {
+ return notFound();
+ }
+
+ const { mdxSource } = await compileMdxContent(post.content);
+ const headings = extractHeadings(post.content);
+ const latestPosts = getLatestPostsForNav();
+
+ return (
+
+ );
+ }
+
+ return notFound();
+}
diff --git a/website/app/blog/page.tsx b/website/app/blog/page.tsx
new file mode 100644
index 00000000000..0278ebbe0c1
--- /dev/null
+++ b/website/app/blog/page.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+
+import { getPaginatedPosts } from "@/lib/blog";
+import { BlogListPage } from "@/lib/blog-list-page";
+import { createMetadata } from "@/lib/metadata";
+
+export const metadata = createMetadata({ title: "Blog" });
+
+export default function BlogPage() {
+ const { posts, totalPages } = getPaginatedPosts(1);
+
+ return (
+
+ );
+}
diff --git a/website/app/blog/tags/[tag]/[page]/page.tsx b/website/app/blog/tags/[tag]/[page]/page.tsx
new file mode 100644
index 00000000000..0f23b61d3ca
--- /dev/null
+++ b/website/app/blog/tags/[tag]/[page]/page.tsx
@@ -0,0 +1,45 @@
+import React from "react";
+
+import { getAllTags, getPostsByTag, getPostsPerPage } from "@/lib/blog";
+import { BlogListPage } from "@/lib/blog-list-page";
+import { createMetadata } from "@/lib/metadata";
+
+interface PageProps {
+ params: Promise<{ tag: string; page: string }>;
+}
+
+export async function generateStaticParams() {
+ const tags = getAllTags();
+ const postsPerPage = getPostsPerPage();
+ const params: { tag: string; page: string }[] = [];
+
+ for (const tag of tags) {
+ const { totalPages } = getPostsByTag(tag, 1);
+ for (let i = 2; i <= totalPages; i++) {
+ params.push({ tag, page: String(i) });
+ }
+ }
+
+ return params;
+}
+
+export async function generateMetadata({ params }: PageProps) {
+ const { tag, page } = await params;
+ return createMetadata({ title: `Blog - ${tag} - Page ${page}` });
+}
+
+export default async function BlogTagPaginatedPage({ params }: PageProps) {
+ const { tag, page } = await params;
+ const pageNum = parseInt(page, 10);
+ const { posts, totalPages } = getPostsByTag(tag, pageNum);
+
+ return (
+
+ );
+}
diff --git a/website/app/blog/tags/[tag]/page.tsx b/website/app/blog/tags/[tag]/page.tsx
new file mode 100644
index 00000000000..bf5a7916be8
--- /dev/null
+++ b/website/app/blog/tags/[tag]/page.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+
+import { getAllTags, getPostsByTag } from "@/lib/blog";
+import { BlogListPage } from "@/lib/blog-list-page";
+import { createMetadata } from "@/lib/metadata";
+
+interface PageProps {
+ params: Promise<{ tag: string }>;
+}
+
+export async function generateStaticParams() {
+ return getAllTags().map((tag) => ({ tag }));
+}
+
+export async function generateMetadata({ params }: PageProps) {
+ const { tag } = await params;
+ return createMetadata({ title: `Blog - ${tag}` });
+}
+
+export default async function BlogTagPage({ params }: PageProps) {
+ const { tag } = await params;
+ const { posts, totalPages } = getPostsByTag(tag, 1);
+
+ return (
+
+ );
+}
diff --git a/website/app/docs/[...slug]/page.tsx b/website/app/docs/[...slug]/page.tsx
new file mode 100644
index 00000000000..1da448caa79
--- /dev/null
+++ b/website/app/docs/[...slug]/page.tsx
@@ -0,0 +1,57 @@
+import React from "react";
+
+import { getAllDocPages, getDocPageBySlug, getDocsConfig } from "@/lib/docs";
+import { compileMdxContent, extractHeadings } from "@/lib/mdx";
+import { createMetadata } from "@/lib/metadata";
+import { DocPageView } from "@/lib/doc-page-view";
+import { notFound } from "next/navigation";
+
+interface PageProps {
+ params: Promise<{ slug: string[] }>;
+}
+
+export async function generateStaticParams() {
+ const pages = getAllDocPages();
+
+ return pages.map((page) => ({
+ slug: page.slug.replace(/^\/docs\//, "").split("/"),
+ }));
+}
+
+export async function generateMetadata({ params }: PageProps) {
+ const { slug } = await params;
+ const fullSlug = "/docs/" + slug.join("/");
+ const page = getDocPageBySlug(fullSlug);
+
+ const title =
+ page?.frontmatter?.title || slug[slug.length - 1] || "Documentation";
+
+ return createMetadata({
+ title,
+ description: page?.frontmatter?.description,
+ });
+}
+
+export default async function DocPage({ params }: PageProps) {
+ const { slug } = await params;
+ const fullSlug = "/docs/" + slug.join("/");
+ const page = getDocPageBySlug(fullSlug);
+
+ if (!page) {
+ return notFound();
+ }
+
+ const { mdxSource } = await compileMdxContent(page.content, page.originPath);
+ const docsConfig = getDocsConfig();
+ const headings = extractHeadings(page.content);
+
+ return (
+
+ );
+}
diff --git a/website/app/docs/page.tsx b/website/app/docs/page.tsx
new file mode 100644
index 00000000000..328bb85b56e
--- /dev/null
+++ b/website/app/docs/page.tsx
@@ -0,0 +1,20 @@
+import { redirect } from "next/navigation";
+
+import { getDocsConfig } from "@/lib/docs";
+
+function getRedirectPath(): string {
+ const products = getDocsConfig();
+ const hotchocolate = products.find((p) => p.path === "hotchocolate");
+ const target = hotchocolate || products[0];
+
+ if (target) {
+ const version = target.latestStableVersion;
+ return `/docs/${target.path}${version ? `/${version}` : ""}`;
+ }
+
+ return "/";
+}
+
+export default function DocsIndex() {
+ redirect(getRedirectPath());
+}
diff --git a/website/app/help/page.tsx b/website/app/help/page.tsx
new file mode 100644
index 00000000000..50e302204f8
--- /dev/null
+++ b/website/app/help/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import HelpPage from "@/page-components/help";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/icon.png b/website/app/icon.png
new file mode 100644
index 00000000000..021bd9387e8
Binary files /dev/null and b/website/app/icon.png differ
diff --git a/website/app/layout.tsx b/website/app/layout.tsx
new file mode 100644
index 00000000000..43b145e2a81
--- /dev/null
+++ b/website/app/layout.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import type { Metadata } from "next";
+
+import { Providers } from "@/lib/providers";
+import { siteMetadata } from "@/lib/site-config";
+import { getLatestBlogPostForHeader } from "@/lib/blog";
+
+export const metadata: Metadata = {
+ title: {
+ default: siteMetadata.title,
+ template: `%s - ${siteMetadata.title}`,
+ },
+ description: siteMetadata.description,
+ icons: {
+ icon: "/icon.png",
+ },
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const latestBlogPost = getLatestBlogPostForHeader();
+
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/website/app/legal/[slug]/page.tsx b/website/app/legal/[slug]/page.tsx
new file mode 100644
index 00000000000..2189f4f7e10
--- /dev/null
+++ b/website/app/legal/[slug]/page.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import fs from "fs";
+import path from "path";
+
+import { compileMdxContent, extractHeadings } from "@/lib/mdx";
+import { createMetadata } from "@/lib/metadata";
+import { BasicPageView } from "@/lib/basic-page-view";
+import {
+ readMarkdownFile,
+ getContentDir,
+ getBasicPageNavLinks,
+} from "@/lib/content";
+import { notFound } from "next/navigation";
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+}
+
+const LEGAL_DIR = getContentDir("basic", "legal");
+
+export async function generateStaticParams() {
+ if (!fs.existsSync(LEGAL_DIR)) return [];
+
+ const files = fs.readdirSync(LEGAL_DIR).filter((f) => f.endsWith(".md"));
+
+ return files.map((f) => ({
+ slug: f.replace(/\.md$/, ""),
+ }));
+}
+
+export async function generateMetadata({ params }: PageProps) {
+ const { slug } = await params;
+ const filePath = path.join(LEGAL_DIR, `${slug}.md`);
+
+ if (!fs.existsSync(filePath)) {
+ // TODO: What about this
+ return createMetadata({ title: "Not Found" });
+ }
+
+ const { frontmatter } = readMarkdownFile(filePath);
+ return createMetadata({ title: frontmatter.title || slug });
+}
+
+export default async function LegalPage({ params }: PageProps) {
+ const { slug } = await params;
+ const filePath = path.join(LEGAL_DIR, `${slug}.md`);
+
+ if (!fs.existsSync(filePath)) {
+ return notFound();
+ }
+
+ const { frontmatter, content: rawContent } = readMarkdownFile(filePath);
+ const { mdxSource } = await compileMdxContent(rawContent);
+ const headings = extractHeadings(rawContent);
+ const navigationLinks = getBasicPageNavLinks();
+
+ return (
+
+ );
+}
diff --git a/website/app/licensing/[slug]/page.tsx b/website/app/licensing/[slug]/page.tsx
new file mode 100644
index 00000000000..6359bf0a74e
--- /dev/null
+++ b/website/app/licensing/[slug]/page.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import fs from "fs";
+import path from "path";
+
+import { compileMdxContent, extractHeadings } from "@/lib/mdx";
+import { createMetadata } from "@/lib/metadata";
+import { BasicPageView } from "@/lib/basic-page-view";
+import {
+ readMarkdownFile,
+ getContentDir,
+ getBasicPageNavLinks,
+} from "@/lib/content";
+import { notFound } from "next/navigation";
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+}
+
+const LICENSING_DIR = getContentDir("basic", "licensing");
+
+export async function generateStaticParams() {
+ if (!fs.existsSync(LICENSING_DIR)) return [];
+
+ const files = fs.readdirSync(LICENSING_DIR).filter((f) => f.endsWith(".md"));
+
+ return files.map((f) => ({
+ slug: f.replace(/\.md$/, ""),
+ }));
+}
+
+export async function generateMetadata({ params }: PageProps) {
+ const { slug } = await params;
+ const filePath = path.join(LICENSING_DIR, `${slug}.md`);
+
+ if (!fs.existsSync(filePath)) {
+ // TODO: What about this
+ return createMetadata({ title: "Not Found" });
+ }
+
+ const { frontmatter } = readMarkdownFile(filePath);
+ return createMetadata({ title: frontmatter.title || slug });
+}
+
+export default async function LicensingPage({ params }: PageProps) {
+ const { slug } = await params;
+ const filePath = path.join(LICENSING_DIR, `${slug}.md`);
+
+ if (!fs.existsSync(filePath)) {
+ return notFound();
+ }
+
+ const { frontmatter, content: rawContent } = readMarkdownFile(filePath);
+ const { mdxSource } = await compileMdxContent(rawContent);
+ const headings = extractHeadings(rawContent);
+ const navigationLinks = getBasicPageNavLinks();
+
+ return (
+
+ );
+}
diff --git a/website/app/not-found.tsx b/website/app/not-found.tsx
new file mode 100644
index 00000000000..fbaa774f820
--- /dev/null
+++ b/website/app/not-found.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import React, { ReactNode } from "react";
+import styled from "styled-components";
+import NextLink from "next/link";
+
+import { SiteLayout } from "@/components/layout";
+
+export default function NotFoundPage() {
+ return (
+
+
+
+ NOT FOUND
+
+ The page you're looking for doesn't exist.
+ Return to the homepage
+
+
+
+
+ );
+}
+
+const Container = styled.div`
+ display: flex;
+ flex: 0 0 auto;
+ flex-direction: row;
+ width: 100%;
+
+ @media only screen and (min-width: 860px) {
+ padding: 20px 10px 0;
+ max-width: 820px;
+ }
+`;
+
+const Article = styled.article`
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ margin-bottom: 60px;
+ padding-bottom: 20px;
+
+ @media only screen and (min-width: 860px) {
+ border-radius: var(--border-radius);
+ }
+`;
+
+const Title = styled.h1`
+ margin-top: 20px;
+ margin-right: 20px;
+ margin-left: 20px;
+ font-size: 2rem;
+
+ @media only screen and (min-width: 860px) {
+ margin-right: 50px;
+ margin-left: 50px;
+ }
+`;
+
+const Content = styled.div`
+ > * {
+ padding-right: 20px;
+ padding-left: 20px;
+ line-height: normal;
+ }
+
+ @media only screen and (min-width: 860px) {
+ > * {
+ padding-right: 50px;
+ padding-left: 50px;
+ }
+ }
+`;
diff --git a/website/app/page.tsx b/website/app/page.tsx
new file mode 100644
index 00000000000..a9832a026c6
--- /dev/null
+++ b/website/app/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import IndexPage from "@/page-components/index";
+
+export default function HomePage() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/platform/analytics/page.tsx b/website/app/platform/analytics/page.tsx
new file mode 100644
index 00000000000..932d05274b8
--- /dev/null
+++ b/website/app/platform/analytics/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import AnalyticsPage from "@/page-components/platform/analytics";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/platform/continuous-integration/page.tsx b/website/app/platform/continuous-integration/page.tsx
new file mode 100644
index 00000000000..ec44ccb015f
--- /dev/null
+++ b/website/app/platform/continuous-integration/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import CIPage from "@/page-components/platform/continuous-integration";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/platform/ecosystem/page.tsx b/website/app/platform/ecosystem/page.tsx
new file mode 100644
index 00000000000..8e6bb59cd39
--- /dev/null
+++ b/website/app/platform/ecosystem/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import EcosystemPage from "@/page-components/platform/ecosystem";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx
new file mode 100644
index 00000000000..2e9aff0af3c
--- /dev/null
+++ b/website/app/pricing/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import PricingPage from "@/page-components/pricing";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/products/hotchocolate/page.tsx b/website/app/products/hotchocolate/page.tsx
new file mode 100644
index 00000000000..d3ca2482260
--- /dev/null
+++ b/website/app/products/hotchocolate/page.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+export default function HotChocolateRedirect() {
+ const router = useRouter();
+
+ useEffect(() => {
+ router.replace("/docs/hotchocolate/v15");
+ }, [router]);
+
+ return null;
+}
diff --git a/website/app/products/nitro/page.tsx b/website/app/products/nitro/page.tsx
new file mode 100644
index 00000000000..48b6a0f1846
--- /dev/null
+++ b/website/app/products/nitro/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentNitroBlogPostTeasers } from "@/lib/blog";
+import NitroPage from "@/page-components/products/nitro";
+
+export default function Page() {
+ const recentPosts = getRecentNitroBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/products/strawberryshake/page.tsx b/website/app/products/strawberryshake/page.tsx
new file mode 100644
index 00000000000..dc12cfe1de8
--- /dev/null
+++ b/website/app/products/strawberryshake/page.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+export default function StrawberryShakeRedirect() {
+ const router = useRouter();
+
+ useEffect(() => {
+ router.replace("/docs/strawberryshake/v15");
+ }, [router]);
+
+ return null;
+}
diff --git a/website/app/services/advisory/page.tsx b/website/app/services/advisory/page.tsx
new file mode 100644
index 00000000000..29189077a93
--- /dev/null
+++ b/website/app/services/advisory/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import AdvisoryPage from "@/page-components/services/advisory";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/services/support/contact/page.tsx b/website/app/services/support/contact/page.tsx
new file mode 100644
index 00000000000..14215e8a268
--- /dev/null
+++ b/website/app/services/support/contact/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import ContactPage from "@/page-components/services/support/contact";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/services/support/page.tsx b/website/app/services/support/page.tsx
new file mode 100644
index 00000000000..7dfb56e41e4
--- /dev/null
+++ b/website/app/services/support/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import SupportPage from "@/page-components/services/support";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/services/support/thank-you/page.tsx b/website/app/services/support/thank-you/page.tsx
new file mode 100644
index 00000000000..af733a20ae9
--- /dev/null
+++ b/website/app/services/support/thank-you/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import ThankYouPage from "@/page-components/services/support/thank-you";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/app/services/training/page.tsx b/website/app/services/training/page.tsx
new file mode 100644
index 00000000000..8692de9c54f
--- /dev/null
+++ b/website/app/services/training/page.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+import { getRecentBlogPostTeasers } from "@/lib/blog";
+import TrainingPage from "@/page-components/services/training";
+
+export default function Page() {
+ const recentPosts = getRecentBlogPostTeasers();
+ return ;
+}
diff --git a/website/config/conf.d/default.conf b/website/config/conf.d/default.conf
index 95935d7c0f4..56eedfebabe 100644
--- a/website/config/conf.d/default.conf
+++ b/website/config/conf.d/default.conf
@@ -2,7 +2,7 @@ server {
listen 80;
root /usr/share/nginx/html;
- error_page 404 /404/index.html;
+ error_page 404 /404.html;
set $latestStableVersion v14;
diff --git a/website/gatsby-browser.js b/website/gatsby-browser.js
deleted file mode 100644
index ba2b433d413..00000000000
--- a/website/gatsby-browser.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// https://github.com/FormidableLabs/prism-react-renderer/issues/53#issuecomment-546653848
-import Prism from "prismjs";
-import "./src/style/prism-theme.css";
-
-(typeof global !== "undefined" ? global : window).Prism = Prism;
-require("prismjs/components/prism-csharp");
-require("prismjs/components/prism-graphql");
-require("prismjs/components/prism-json");
-require("prismjs/components/prism-bash");
-require("prismjs/components/prism-sql");
-require("prismjs/components/prism-diff");
diff --git a/website/gatsby-config.js b/website/gatsby-config.js
deleted file mode 100644
index 6ae3344b510..00000000000
--- a/website/gatsby-config.js
+++ /dev/null
@@ -1,299 +0,0 @@
-const SITE_URL = `https://chillicream.com`;
-
-/** @type import('gatsby').GatsbyConfig */
-module.exports = {
- siteMetadata: {
- title: `ChilliCream GraphQL Platform`,
- description: `We help companies and developers to build next level APIs with GraphQL by providing them the right tooling.`,
- author: `Chilli_Cream`,
- company: "ChilliCream",
- siteUrl: SITE_URL,
- repositoryUrl: `https://github.com/ChilliCream/graphql-platform`,
- tools: {
- blog: `/blog`,
- github: `https://github.com/ChilliCream/graphql-platform`,
- linkedIn: `https://www.linkedin.com/company/chillicream`,
- nitro: `https://nitro.chillicream.com`,
- shop: `https://store.chillicream.com`,
- slack: `https://slack.chillicream.com/`,
- youtube: `https://www.youtube.com/c/ChilliCream`,
- x: `https://x.com/Chilli_Cream`,
- },
- },
- plugins: [
- `gatsby-plugin-graphql-codegen`,
- `gatsby-plugin-styled-components`,
- `gatsby-plugin-react-helmet`,
- `gatsby-plugin-robots-txt`,
- `gatsby-plugin-tsconfig-paths`,
- `gatsby-remark-reading-time`,
- {
- resolve: `gatsby-plugin-mdx`,
- options: {
- extensions: [`.mdx`, `.md`],
- gatsbyRemarkPlugins: [
- {
- resolve: `gatsby-remark-mermaid`,
- options: {
- mermaidOptions: {
- fontFamily: "sans-serif",
- sequence: { showSequenceNumbers: true },
- },
- },
- },
- {
- resolve: `gatsby-remark-images`,
- options: {
- maxWidth: 800,
- quality: 100,
- backgroundColor: "transparent",
- },
- },
- {
- resolve: `gatsby-remark-autolink-headers`,
- options: {
- icon: ``,
- },
- },
- {
- resolve: require.resolve(`./plugins/gatsby-remark-gather-links`),
- },
- {
- resolve: "gatsby-remark-external-links",
- options: {
- target: "_blank",
- rel: "noopener noreferrer",
- },
- },
- ],
- },
- },
- {
- resolve: `gatsby-plugin-react-redux`,
- options: {
- pathToCreateStoreModule: `./src/state`,
- },
- },
- {
- resolve: `gatsby-source-filesystem`,
- options: {
- name: `basic`,
- path: `${__dirname}/src/basic`,
- },
- },
- {
- resolve: `gatsby-source-filesystem`,
- options: {
- name: `blog`,
- path: `${__dirname}/src/blog`,
- },
- },
- {
- resolve: `gatsby-source-filesystem`,
- options: {
- name: `docs`,
- path: `${__dirname}/src/docs`,
- },
- },
- {
- resolve: `gatsby-source-filesystem`,
- options: {
- name: `images`,
- path: `${__dirname}/src/images`,
- },
- },
- {
- resolve: require.resolve(`./plugins/gatsby-plugin-validate-links`),
- },
- {
- resolve: `gatsby-plugin-react-svg`,
- options: {
- rule: {
- include: /images/,
- exclude: /images\/(artwork|companies|icons|logo)\/.*\.svg$/,
- },
- },
- },
- {
- resolve: require.resolve(`./plugins/gatsby-plugin-svg-sprite`),
- options: {
- rule: {
- test: /images\/(artwork|companies|icons|logo)\/.*\.svg$/,
- },
- },
- },
- {
- resolve: `gatsby-plugin-disqus`,
- options: {
- shortname: `chillicream`,
- },
- },
- `gatsby-transformer-json`,
- `gatsby-plugin-image`,
- {
- resolve: `gatsby-plugin-sharp`,
- options: {
- quality: 100,
- },
- },
- `gatsby-transformer-sharp`,
- {
- resolve: "gatsby-plugin-web-font-loader",
- options: {
- google: {
- families: ["Radio Canada:400,500,600,700"],
- },
- },
- },
- {
- resolve: `gatsby-plugin-manifest`,
- options: {
- name: `ChilliCream GraphQL`,
- short_name: `ChilliCream`,
- start_url: `/`,
- background_color: `#0a0721`,
- theme_color: `#0a0721`,
- display: `standalone`,
- icon: `src/images/chillicream-favicon.png`,
- },
- },
- {
- resolve: `gatsby-plugin-sitemap`,
- options: {
- resolvePagePath({ path }) {
- return `${path}/`.replace("//", "/");
- },
- },
- },
- {
- resolve: `gatsby-plugin-robots-txt`,
- options: {
- host: SITE_URL,
- sitemap: `${SITE_URL}/sitemap-index.xml`,
- policy: [
- {
- userAgent: `*`,
- allow: `/`,
- disallow: [`/docs/hotchocolate/v10/`, `/docs/hotchocolate/v11/`],
- },
- {
- userAgent: `Algolia Crawler`,
- allow: `/`,
- },
- ],
- },
- },
- {
- resolve: `gatsby-plugin-feed`,
- options: {
- baseUrl: `https://chillicream.com`,
- query: `{
- site {
- siteMetadata {
- title
- description
- siteUrl
- author
- company
- }
- pathPrefix
- }
- }`,
- setup: (options) => {
- const { pathPrefix } = options.query.site;
- const { author, company, description, siteUrl, title } =
- options.query.site.siteMetadata;
- const baseUrl = siteUrl + pathPrefix;
- const currentYear = new Date().getUTCFullYear();
-
- return {
- ...options,
- id: baseUrl,
- title,
- site_url: baseUrl,
- description,
- copyright: `All rights reserved ${currentYear}, ${company}`,
- author,
- generator: "ChilliCream",
- image: `${baseUrl}/favicon-32x32.png`,
- favicon: `${baseUrl}/favicon-32x32.png`,
- feedLinks: {
- atom: `${baseUrl}/atom.xml`,
- json: `${baseUrl}/feed.json`,
- rss: `${baseUrl}/rss.xml`,
- },
- categories: ["GraphQL", "Products", "Services"],
- };
- },
- feeds: [
- {
- query: `{
- allMdx(
- limit: 100
- filter: { frontmatter: { path: { regex: "//blog(/.*)?/" } } }
- sort: { order: DESC, fields: [frontmatter___date] },
- ) {
- edges {
- node {
- excerpt
- body
- frontmatter {
- title
- author
- authorUrl
- date
- path
- featuredImage {
- childImageSharp {
- gatsbyImageData(layout: CONSTRAINED, width: 800, quality: 100)
- }
- }
- }
- }
- }
- }
- }`,
- serialize: ({
- query: {
- allMdx,
- site: {
- pathPrefix,
- siteMetadata: { siteUrl },
- },
- },
- }) =>
- allMdx.edges.map(({ node: { excerpt, frontmatter, body } }) => {
- const date = new Date(Date.parse(frontmatter.date));
- const imgSrcPattern = new RegExp(
- `(${pathPrefix})?/static/`,
- "g"
- );
- const link = siteUrl + pathPrefix + frontmatter.path;
- let image = frontmatter.featuredImage
- ? siteUrl +
- frontmatter.featuredImage.childImageSharp.gatsbyImageData
- .src
- : null;
-
- return {
- url: link,
- title: frontmatter.title,
- date,
- published: date,
- description: excerpt,
- content: body.replace(imgSrcPattern, `${siteUrl}/static/`),
- image,
- author: frontmatter.author,
- };
- }),
- title: "ChilliCream Blog",
- output: "/rss.xml",
- },
- ],
- },
- },
- // this (optional) plugin enables Progressive Web App + Offline functionality
- // To learn more, visit: https://gatsby.dev/offline
- // `gatsby-plugin-offline`,
- ],
-};
diff --git a/website/gatsby-node.js b/website/gatsby-node.js
deleted file mode 100644
index 491b0058c15..00000000000
--- a/website/gatsby-node.js
+++ /dev/null
@@ -1,266 +0,0 @@
-const { createFilePath } = require("gatsby-source-filesystem");
-const path = require("path");
-const git = require("simple-git/promise");
-
-/** @type import('gatsby').GatsbyNode["createPages"] */
-exports.createPages = async ({ actions, graphql, reporter }) => {
- const { createPage, createRedirect } = actions;
-
- const result = await graphql(`
- {
- basic: allFile(
- limit: 1000
- filter: { sourceInstanceName: { eq: "basic" }, extension: { eq: "md" } }
- ) {
- pages: nodes {
- name
- relativeDirectory
- childMdx {
- fields {
- slug
- }
- }
- }
- }
- blog: allMdx(
- limit: 1000
- filter: { frontmatter: { path: { regex: "//blog(/.*)?/" } } }
- sort: { order: DESC, fields: [frontmatter___date] }
- ) {
- posts: nodes {
- fields {
- slug
- }
- frontmatter {
- tags
- }
- }
- tags: group(field: frontmatter___tags) {
- fieldValue
- }
- }
- docs: allFile(
- limit: 1000
- filter: { sourceInstanceName: { eq: "docs" }, extension: { eq: "md" } }
- ) {
- pages: nodes {
- name
- relativeDirectory
- childMdx {
- fields {
- slug
- }
- }
- }
- }
- productsConfig: file(
- sourceInstanceName: { eq: "docs" }
- relativePath: { eq: "docs.json" }
- ) {
- products: childrenDocsJson {
- path
- latestStableVersion
- }
- }
- }
- `);
-
- // Handle errors
- if (result.errors) {
- reporter.panicOnBuild(`Error while running GraphQL query.`);
- return;
- }
-
- createDefaultArticles(createPage, result.data.basic);
- createBlogArticles(createPage, result.data.blog);
- createDocArticles(createPage, result.data.docs);
-
- const products = result.data.productsConfig.products;
- const latestHcVersion = products?.find(
- (product) => product?.path === "hotchocolate"
- )?.latestStableVersion;
- const latestSsVersion = products?.find(
- (product) => product?.path === "strawberryshake"
- )?.latestStableVersion;
-
- // temporary client-side redirects for missing product pages
- // need to be kept till the product pages are created
- // for SEO we have also configured redirects in NGINX
- createRedirect({
- fromPath: `/products/hotchocolate`,
- toPath: `/docs/hotchocolate/${latestHcVersion}`,
- redirectInBrowser: true,
- isPermanent: false,
- });
- createRedirect({
- fromPath: `/products/strawberryshake`,
- toPath: `/docs/strawberryshake/${latestSsVersion}`,
- redirectInBrowser: true,
- isPermanent: false,
- });
-};
-
-exports.onCreateNode = async ({ node, actions, getNode, reporter }) => {
- const { createNodeField } = actions;
-
- if (node.internal.type !== `Mdx`) {
- return;
- }
-
- // if the path is defined on the frontmatter (like for blogs) use that as slug
- let path = node.frontmatter && node.frontmatter.path;
-
- if (!path) {
- path = createFilePath({ node, getNode });
-
- const parent = getNode(node.parent);
-
- // if the current file is emitted from the docs directory
- if (parent && parent.sourceInstanceName === "docs") {
- path = "/docs" + path;
- }
-
- // remove trailing slashes
- path = path.replace(/\/+$/, "");
- }
-
- createNodeField({
- name: `slug`,
- node,
- value: path,
- });
-
- let authorName = "Unknown";
- let lastUpdated = "0000-00-00";
-
- // we only run "git log" when building the production bundle
- // for development purposes we fallback to dummy values
- if (process.env.NODE_ENV === "production") {
- try {
- const result = await getGitLog(node.fileAbsolutePath);
- const data = result.latest || {};
-
- if (data.authorName) {
- authorName = data.authorName;
- }
-
- if (data.date) {
- lastUpdated = data.date;
- }
- } catch (error) {
- reporter.error(
- `Could not retrieve git information for ${node.fileAbsolutePath}`,
- error
- );
- }
- }
-
- createNodeField({
- node,
- name: `lastAuthorName`,
- value: authorName,
- });
- createNodeField({
- node,
- name: `lastUpdated`,
- value: lastUpdated,
- });
-};
-
-function createDefaultArticles(createPage, data) {
- const component = path.resolve(`src/templates/default-article-template.tsx`);
-
- data.pages.forEach((page) => {
- createPage({
- path: page.childMdx.fields.slug,
- component,
- context: {
- originPath: `${page.relativeDirectory}/${page.name}.md`,
- },
- });
- });
-}
-
-function createBlogArticles(createPage, data) {
- // Create Single Pages
- data.posts.forEach((post) => {
- createPage({
- path: post.fields.slug,
- component: path.resolve(`src/templates/blog-article-template.tsx`),
- context: {},
- });
- });
-
- const postsPerPage = 21;
-
- // Create List Pages
- const numPagesAllPosts = Math.ceil(data.posts.length / postsPerPage);
-
- Array.from({ length: numPagesAllPosts }).forEach((_, i) => {
- createPage({
- path: i === 0 ? `/blog` : `/blog/${i + 1}`,
- component: path.resolve(`src/templates/blog-articles-template.tsx`),
- context: {
- limit: postsPerPage,
- skip: i * postsPerPage,
- numPages: numPagesAllPosts,
- currentPage: i + 1,
- },
- });
- });
-
- // Create Tag Pages
- data.tags.forEach(({ fieldValue: tag }) => {
- const numPagesPostsByTags = Math.ceil(
- data.posts.filter(({ frontmatter: { tags } }) => tags.includes(tag))
- .length / postsPerPage
- );
-
- Array.from({ length: numPagesPostsByTags }).forEach((_, i) => {
- createPage({
- path: i === 0 ? `/blog/tags/${tag}` : `/blog/tags/${tag}/${i + 1}`,
- component: path.resolve(
- `src/templates/blog-articles-by-tag-template.tsx`
- ),
- context: {
- tag,
- limit: postsPerPage,
- skip: i * postsPerPage,
- numPages: numPagesPostsByTags,
- currentPage: i + 1,
- },
- });
- });
- });
-}
-
-function createDocArticles(createPage, data) {
- const component = path.resolve(`src/templates/doc-article-template.tsx`);
-
- // Create Single Pages
- data.pages.forEach((page) => {
- const path = page.childMdx.fields.slug;
- const originPath = `${page.relativeDirectory}/${page.name}.md`;
-
- createPage({
- path,
- component,
- context: {
- originPath,
- },
- });
- });
-}
-
-function getGitLog(filepath) {
- const logOptions = {
- file: filepath,
- n: 1,
- format: {
- date: `%cs`,
- authorName: `%an`,
- },
- };
-
- return git().log(logOptions);
-}
diff --git a/website/gatsby-ssr.js b/website/gatsby-ssr.js
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/website/lib/basic-page-view.tsx b/website/lib/basic-page-view.tsx
new file mode 100644
index 00000000000..21e179196a2
--- /dev/null
+++ b/website/lib/basic-page-view.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import React from "react";
+import { MDXRemoteSerializeResult } from "next-mdx-remote";
+
+import {
+ ArticleContent,
+ ArticleHeader,
+ ArticleTitle,
+} from "@/components/article-elements";
+import { ArticleLayout, SiteLayout } from "@/components/layout";
+import { SEO } from "@/components/misc";
+import { ArticleTableOfContent } from "@/components/articles/article-table-of-content";
+import { DefaultArticleNavigation } from "@/components/articles/default-article-navigation";
+import { ResponsiveArticleMenu } from "@/components/articles/responsive-article-menu";
+import { MdxContent } from "./mdx-content";
+
+interface NavLink {
+ path: string;
+ title: string;
+}
+
+interface BasicPageViewProps {
+ title: string;
+ slug: string;
+ mdxSource: MDXRemoteSerializeResult;
+ headings: Array<{ depth: number; value: string }>;
+ navigationLinks: NavLink[];
+}
+
+export function BasicPageView({
+ title,
+ slug,
+ mdxSource,
+ headings,
+ navigationLinks,
+}: BasicPageViewProps) {
+ const navigationData = {
+ navigation: {
+ links: navigationLinks,
+ },
+ };
+
+ const tocData = {
+ headings,
+ };
+
+ return (
+
+
+
+ }
+ aside={}
+ >
+
+
+ {title}
+
+
+
+
+
+
+ );
+}
diff --git a/website/lib/blog-list-page.tsx b/website/lib/blog-list-page.tsx
new file mode 100644
index 00000000000..39ccd55aede
--- /dev/null
+++ b/website/lib/blog-list-page.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import React from "react";
+
+import { SiteLayout } from "@/components/layout";
+import { SEO } from "@/components/misc";
+import { AllBlogPosts } from "@/components/widgets";
+import type { BlogPost } from "./blog";
+
+interface BlogListPageProps {
+ posts: BlogPost[];
+ currentPage: number;
+ totalPages: number;
+ linkPrefix: string;
+ tag?: string;
+}
+
+export function BlogListPage({
+ posts,
+ currentPage,
+ totalPages,
+ linkPrefix,
+ tag,
+}: BlogListPageProps) {
+ const title = tag ? `Blog - ${tag}` : "Blog";
+ const description = tag
+ ? `Posts tagged with "${tag}"`
+ : "The latest news about ChilliCream and our products";
+
+ const data = {
+ edges: posts.map((post) => ({
+ node: {
+ id: post.slug,
+ frontmatter: {
+ featuredImage: post.featuredImage || undefined,
+ path: post.slug,
+ title: post.title,
+ author: post.author || undefined,
+ authorImageUrl: undefined,
+ date: post.date,
+ },
+ fields: {
+ readingTime: {
+ text: post.readingTime,
+ },
+ },
+ },
+ })),
+ };
+
+ return (
+
+
+
+
+ );
+}
diff --git a/website/lib/blog-post-page.tsx b/website/lib/blog-post-page.tsx
new file mode 100644
index 00000000000..7a88efd3ef2
--- /dev/null
+++ b/website/lib/blog-post-page.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import React from "react";
+import { MDXRemoteSerializeResult } from "next-mdx-remote";
+
+import { SiteLayout } from "@/components/layout";
+import { SEO } from "@/components/misc";
+import { BlogArticle } from "@/components/articles/blog-article";
+import { MdxContent } from "./mdx-content";
+import type { BlogPost } from "./blog";
+
+interface LatestPost {
+ fields: { slug: string };
+ frontmatter: { title: string };
+}
+
+interface BlogPostPageProps {
+ post: BlogPost;
+ mdxSource: MDXRemoteSerializeResult;
+ headings: Array<{ depth: number; value: string }>;
+ latestPosts: LatestPost[];
+}
+
+export function BlogPostPage({
+ post,
+ mdxSource,
+ headings,
+ latestPosts,
+}: BlogPostPageProps) {
+ const formattedDate = new Date(post.date).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ });
+
+ const data = {
+ mdx: {
+ fields: {
+ slug: post.slug,
+ readingTime: { text: post.readingTime },
+ },
+ frontmatter: {
+ featuredImage: post.featuredImage,
+ featuredVideoId: post.featuredVideoId,
+ path: post.path,
+ title: post.title,
+ description: post.description,
+ tags: post.tags,
+ author: post.author,
+ authorImageUrl: post.authorImageUrl,
+ authorUrl: post.authorUrl,
+ date: formattedDate,
+ },
+ headings,
+ },
+ latestPosts: {
+ posts: latestPosts,
+ },
+ };
+
+ return (
+
+
+ } />
+
+ );
+}
diff --git a/website/lib/blog.ts b/website/lib/blog.ts
new file mode 100644
index 00000000000..81deac1c493
--- /dev/null
+++ b/website/lib/blog.ts
@@ -0,0 +1,203 @@
+import path from "path";
+import readingTime from "reading-time";
+
+import { getContentDir, getFilesRecursively, readMarkdownFile } from "./content";
+
+export interface BlogPost {
+ slug: string;
+ title: string;
+ description?: string;
+ author: string;
+ authorUrl?: string;
+ authorImageUrl?: string;
+ date: string;
+ tags: string[];
+ featuredImage?: string;
+ featuredVideoId?: string;
+ content: string;
+ readingTime: string;
+ path: string;
+}
+
+const BLOG_DIR = getContentDir("blog");
+const POSTS_PER_PAGE = 21;
+
+let _cachedPosts: BlogPost[] | null = null;
+
+export function getAllBlogPosts(): BlogPost[] {
+ if (_cachedPosts) return _cachedPosts;
+
+ const files = getFilesRecursively(BLOG_DIR, ".md");
+ const posts: BlogPost[] = [];
+
+ for (const file of files) {
+ const { frontmatter, content } = readMarkdownFile(file);
+
+ if (!frontmatter.path || !frontmatter.path.startsWith("/blog/")) continue;
+
+ // Resolve featured image path relative to blog post
+ let featuredImage: string | undefined;
+ if (frontmatter.featuredImage) {
+ const imgPath = frontmatter.featuredImage;
+ if (typeof imgPath === "string") {
+ const dir = path.dirname(file);
+ const absImgPath = path.resolve(dir, imgPath);
+ const relToSrc = path.relative(getContentDir(), absImgPath);
+ featuredImage = `/images/${relToSrc}`;
+ }
+ }
+
+ posts.push({
+ slug: frontmatter.path,
+ title: frontmatter.title || "",
+ description: frontmatter.description || "",
+ author: frontmatter.author || "Unknown",
+ authorUrl: frontmatter.authorUrl || "",
+ authorImageUrl: frontmatter.authorImageUrl || "",
+ date: frontmatter.date
+ ? new Date(frontmatter.date).toISOString()
+ : "",
+ tags: frontmatter.tags || [],
+ featuredImage,
+ featuredVideoId: frontmatter.featuredVideoId || undefined,
+ content,
+ readingTime: readingTime(content).text,
+ path: frontmatter.path,
+ });
+ }
+
+ // Sort by date descending
+ posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+
+ _cachedPosts = posts;
+ return posts;
+}
+
+export function getBlogPostBySlug(slug: string): BlogPost | undefined {
+ return getAllBlogPosts().find((p) => p.slug === slug);
+}
+
+export function getPaginatedPosts(page: number) {
+ const posts = getAllBlogPosts();
+ const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE);
+ const start = (page - 1) * POSTS_PER_PAGE;
+ const paginatedPosts = posts.slice(start, start + POSTS_PER_PAGE);
+
+ return {
+ posts: paginatedPosts,
+ currentPage: page,
+ totalPages,
+ totalPosts: posts.length,
+ };
+}
+
+export function getAllTags(): string[] {
+ const posts = getAllBlogPosts();
+ const tagSet = new Set();
+ for (const post of posts) {
+ for (const tag of post.tags) {
+ tagSet.add(tag);
+ }
+ }
+ return Array.from(tagSet).sort();
+}
+
+export function getPostsByTag(tag: string, page: number) {
+ const allPosts = getAllBlogPosts().filter((p) => p.tags.includes(tag));
+ const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
+ const start = (page - 1) * POSTS_PER_PAGE;
+ const paginatedPosts = allPosts.slice(start, start + POSTS_PER_PAGE);
+
+ return {
+ posts: paginatedPosts,
+ currentPage: page,
+ totalPages,
+ totalPosts: allPosts.length,
+ tag,
+ };
+}
+
+export function getPostsPerPage() {
+ return POSTS_PER_PAGE;
+}
+
+export function getLatestPostsForNav(count = 10) {
+ return getAllBlogPosts()
+ .slice(0, count)
+ .map((post) => ({
+ fields: { slug: post.slug },
+ frontmatter: { title: post.title },
+ }));
+}
+
+export function getRecentNitroBlogPostTeasers(count = 3) {
+ const posts = getAllBlogPosts()
+ .filter((p) => p.tags.some((t) => t.toLowerCase() === "nitro"))
+ .slice(0, count);
+
+ return posts.map((post) => ({
+ id: post.slug,
+ frontmatter: {
+ featuredImage: post.featuredImage,
+ path: post.path,
+ title: post.title,
+ author: post.author,
+ authorImageUrl: post.authorImageUrl,
+ date: post.date
+ ? new Date(post.date).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ })
+ : "",
+ },
+ fields: {
+ readingTime: { text: post.readingTime },
+ },
+ }));
+}
+
+export function getLatestBlogPostForHeader() {
+ const posts = getAllBlogPosts();
+ if (posts.length === 0) return null;
+
+ const post = posts[0];
+ return {
+ title: post.title,
+ path: post.path,
+ date: post.date
+ ? new Date(post.date).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ })
+ : "",
+ readingTime: post.readingTime,
+ featuredImage: post.featuredImage,
+ };
+}
+
+export function getRecentBlogPostTeasers(count = 3) {
+ const posts = getAllBlogPosts().slice(0, count);
+
+ return posts.map((post) => ({
+ id: post.slug,
+ frontmatter: {
+ featuredImage: post.featuredImage,
+ path: post.path,
+ title: post.title,
+ author: post.author,
+ authorImageUrl: post.authorImageUrl,
+ date: post.date
+ ? new Date(post.date).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ })
+ : "",
+ },
+ fields: {
+ readingTime: { text: post.readingTime },
+ },
+ }));
+}
diff --git a/website/lib/content.ts b/website/lib/content.ts
new file mode 100644
index 00000000000..5b2726eaf76
--- /dev/null
+++ b/website/lib/content.ts
@@ -0,0 +1,62 @@
+import fs from "fs";
+import path from "path";
+import matter from "gray-matter";
+
+const WEBSITE_DIR = process.cwd();
+
+export function getFilesRecursively(dir: string, ext = ".md"): string[] {
+ const results: string[] = [];
+
+ if (!fs.existsSync(dir)) return results;
+
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ results.push(...getFilesRecursively(fullPath, ext));
+ } else if (entry.name.endsWith(ext)) {
+ results.push(fullPath);
+ }
+ }
+
+ return results;
+}
+
+export function readMarkdownFile(filePath: string) {
+ const raw = fs.readFileSync(filePath, "utf-8");
+ const { data: frontmatter, content } = matter(raw);
+ return { frontmatter, content, filePath };
+}
+
+export function generateSlug(filePath: string, basePath: string): string {
+ let relative = path.relative(basePath, filePath);
+ // Remove extension
+ relative = relative.replace(/\.mdx?$/, "");
+ // Remove trailing /index
+ relative = relative.replace(/\/index$/, "");
+ // Normalize separators
+ return "/" + relative.split(path.sep).join("/");
+}
+
+export function getContentDir(...segments: string[]): string {
+ return path.join(WEBSITE_DIR, "src", ...segments);
+}
+
+interface BasicNavLink {
+ path: string;
+ title: string;
+}
+
+export function getBasicPageNavLinks(): BasicNavLink[] {
+ const jsonPath = path.join(WEBSITE_DIR, "src", "basic", "basic.json");
+
+ if (!fs.existsSync(jsonPath)) return [];
+
+ const data = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
+
+ return (data as BasicNavLink[]).map((link) => ({
+ path: "/" + link.path,
+ title: link.title,
+ }));
+}
diff --git a/website/lib/doc-page-view.tsx b/website/lib/doc-page-view.tsx
new file mode 100644
index 00000000000..6b0dad1041d
--- /dev/null
+++ b/website/lib/doc-page-view.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import React, { useMemo } from "react";
+import { MDXRemoteSerializeResult } from "next-mdx-remote";
+import semverCoerce from "semver/functions/coerce";
+import semverCompare from "semver/functions/compare";
+import styled from "styled-components";
+
+import {
+ ArticleContent,
+ ArticleHeader,
+ ArticleTitle,
+} from "@/components/article-elements";
+import { ArticleLayout, SiteLayout } from "@/components/layout";
+import { Link, SEO } from "@/components/misc";
+import { THEME_COLORS } from "@/style";
+import { ArticleContentFooter } from "@/components/articles/article-content-footer";
+import { ArticleTableOfContent } from "@/components/articles/article-table-of-content";
+import { DocArticleCommunity } from "@/components/articles/doc-article-community";
+import { DocArticleNavigation } from "@/components/articles/doc-article-navigation";
+import { ResponsiveArticleMenu } from "@/components/articles/responsive-article-menu";
+import { MdxContent } from "./mdx-content";
+import type { DocPage, DocsProduct } from "./docs";
+
+interface DocPageViewProps {
+ page: DocPage;
+ mdxSource: MDXRemoteSerializeResult;
+ docsConfig: DocsProduct[];
+ slug: string[];
+ headings: Array<{ depth: number; value: string }>;
+}
+
+export function DocPageView({
+ page,
+ mdxSource,
+ docsConfig,
+ slug,
+ headings,
+}: DocPageViewProps) {
+ const productPath = slug[0];
+ const versionPath =
+ slug.length > 1 && /^v\d+/.test(slug[1]) ? slug[1] : "";
+ const selectedPath = "/docs/" + slug.join("/");
+ const title = page.frontmatter?.title || slug[slug.length - 1];
+ const description = page.frontmatter?.description;
+
+ const navData = { config: { products: docsConfig } };
+
+ const product = useMemo(() => {
+ const selectedProduct = docsConfig.find((p) => p.path === productPath);
+ return {
+ path: productPath,
+ name: selectedProduct?.title ?? "",
+ version: versionPath,
+ stableVersion: selectedProduct?.latestStableVersion ?? "",
+ description: selectedProduct?.metaDescription || null,
+ };
+ }, [docsConfig, productPath, versionPath]);
+
+ return (
+
+
+
+ }
+ aside={
+ <>
+
+
+ >
+ }
+ >
+
+
+
+ {title}
+
+
+ {description && {description}
}
+
+
+
+
+
+ );
+}
+
+// Version warning banner
+
+interface ProductInformation {
+ readonly path: string;
+ readonly name: string | null;
+ readonly version: string;
+ readonly stableVersion: string;
+ readonly description: string | null;
+}
+
+const DocumentationVersionWarning = styled.div`
+ padding: 20px 20px;
+ background-color: ${THEME_COLORS.warning};
+ color: ${THEME_COLORS.textContrast};
+ line-height: 1.4;
+
+ > br {
+ margin-bottom: 16px;
+ }
+
+ > a {
+ color: white !important;
+ font-weight: 600;
+ text-decoration: underline;
+ }
+
+ @media only screen and (min-width: 860px) {
+ padding: 20px 50px;
+ }
+`;
+
+interface DocumentationNotesProps {
+ readonly product: ProductInformation;
+ readonly slug: string;
+}
+
+type DocumentationVersionType = "stable" | "experimental" | "outdated" | null;
+
+function DocumentationNotes({ product, slug }: DocumentationNotesProps) {
+ const versionType = useMemo(() => {
+ const parsedCurrentVersion = semverCoerce(product.version);
+ const parsedStableVersion = semverCoerce(product.stableVersion);
+
+ if (parsedCurrentVersion && parsedStableVersion) {
+ const curVersion = parsedCurrentVersion.version;
+ const stableVersion = parsedStableVersion.version;
+
+ const result = semverCompare(curVersion, stableVersion);
+
+ if (result === 0) {
+ return "stable";
+ }
+
+ if (result === 1) {
+ return "experimental";
+ }
+
+ if (result === -1) {
+ return "outdated";
+ }
+ }
+
+ return null;
+ }, [product.stableVersion, product.version]);
+
+ if (versionType !== null) {
+ const stableDocsUrl = slug.replace(
+ "/" + product.version,
+ "/" + product.stableVersion
+ );
+
+ if (versionType === "experimental") {
+ return (
+
+ This is documentation for {product.version}, which is
+ currently in preview.
+
+ See the latest stable version{" "}
+ instead.
+
+ );
+ }
+
+ if (versionType === "outdated") {
+ return (
+
+ This is documentation for {product.version}, which is
+ no longer actively maintained.
+
+ For up-to-date documentation, see the{" "}
+ latest stable version.
+
+ );
+ }
+ }
+
+ return null;
+}
diff --git a/website/lib/docs.ts b/website/lib/docs.ts
new file mode 100644
index 00000000000..d0599477f38
--- /dev/null
+++ b/website/lib/docs.ts
@@ -0,0 +1,141 @@
+import { execSync } from "child_process";
+import path from "path";
+
+import { getContentDir, getFilesRecursively, readMarkdownFile } from "./content";
+import docsConfig from "../src/docs/docs.json";
+
+export interface DocPage {
+ slug: string;
+ originPath: string;
+ content: string;
+ frontmatter: Record;
+ product?: string;
+ version?: string;
+ lastUpdated?: string;
+ lastAuthorName?: string;
+}
+
+export interface DocsProduct {
+ path: string;
+ title: string;
+ description: string;
+ metaDescription?: string;
+ latestStableVersion: string;
+ versions: DocsVersion[];
+}
+
+export interface DocsVersion {
+ path: string;
+ title: string;
+ items: DocsNavItem[];
+}
+
+export interface DocsNavItem {
+ path: string;
+ title: string;
+ items?: DocsNavItem[];
+}
+
+const DOCS_DIR = getContentDir("docs");
+
+let _cachedDocPages: DocPage[] | null = null;
+
+function getGitMetadata(filePath: string): {
+ lastUpdated: string;
+ lastAuthorName: string;
+} {
+ try {
+ const result = execSync(
+ `git log -1 --format="%ai||%an" -- "${filePath}"`,
+ { encoding: "utf-8", timeout: 5000 }
+ ).trim();
+
+ if (result) {
+ const [dateStr, authorName] = result.split("||");
+ const date = new Date(dateStr);
+ const lastUpdated = date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "2-digit",
+ });
+ return { lastUpdated, lastAuthorName: authorName || "" };
+ }
+ } catch {
+ // Git metadata not available
+ }
+ return { lastUpdated: "", lastAuthorName: "" };
+}
+
+export function getDocsConfig(): DocsProduct[] {
+ return docsConfig as unknown as DocsProduct[];
+}
+
+export function getAllDocPages(): DocPage[] {
+ if (_cachedDocPages) return _cachedDocPages;
+
+ const files = getFilesRecursively(DOCS_DIR, ".md");
+ const pages: DocPage[] = [];
+
+ for (const file of files) {
+ const { frontmatter, content } = readMarkdownFile(file);
+ const relative = path.relative(DOCS_DIR, file);
+ const slug =
+ "/docs/" +
+ relative
+ .replace(/\.mdx?$/, "")
+ .replace(/\/index$/, "")
+ .split(path.sep)
+ .join("/");
+
+ const originPath = relative;
+
+ // Extract product and version from path
+ const parts = relative.split(path.sep);
+ const product = parts[0] || undefined;
+ let version: string | undefined;
+ if (parts.length > 1 && /^v\d+/.test(parts[1])) {
+ version = parts[1];
+ }
+
+ const gitMeta = getGitMetadata(file);
+
+ pages.push({
+ slug,
+ originPath,
+ content,
+ frontmatter,
+ product,
+ version,
+ lastUpdated: gitMeta.lastUpdated,
+ lastAuthorName: gitMeta.lastAuthorName,
+ });
+ }
+
+ _cachedDocPages = pages;
+ return pages;
+}
+
+export function getDocPageBySlug(slug: string): DocPage | undefined {
+ const normalizedSlug = slug.replace(/\/$/, "");
+ return getAllDocPages().find((p) => p.slug === normalizedSlug);
+}
+
+export function getProductInfo(productPath: string): DocsProduct | undefined {
+ return getDocsConfig().find((p) => p.path === productPath);
+}
+
+export function getVersionNav(
+ productPath: string,
+ versionPath: string
+): DocsVersion | undefined {
+ const product = getProductInfo(productPath);
+ if (!product) return undefined;
+ return product.versions.find((v) => v.path === versionPath);
+}
+
+export function getProductRedirectPath(productPath: string): string {
+ const product = getProductInfo(productPath);
+ if (!product) return `/docs/${productPath}`;
+ const version = product.latestStableVersion;
+ return `/docs/${productPath}${version ? `/${version}` : ""}`;
+}
diff --git a/website/lib/mdx-content.tsx b/website/lib/mdx-content.tsx
new file mode 100644
index 00000000000..b48bcc554e7
--- /dev/null
+++ b/website/lib/mdx-content.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import React from "react";
+import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
+
+import { BlockQuote } from "@/components/mdx/block-quote";
+import { CodeBlock } from "@/components/mdx/code-block";
+import {
+ Code,
+ ExampleTabs,
+ Implementation,
+ Schema,
+} from "@/components/mdx/example-tabs";
+import { InlineCode } from "@/components/mdx/inline-code";
+import { PackageInstallation } from "@/components/mdx/package-installation";
+import { Video } from "@/components/mdx/video";
+import { Warning } from "@/components/mdx/warning";
+import { ApiChoiceTabs } from "@/components/mdx/api-choice-tabs";
+import { InputChoiceTabs } from "@/components/mdx/input-choice-tabs";
+import { List, Panel, Tab, Tabs } from "@/components/mdx/tabs";
+
+const mdxComponents = {
+ pre: CodeBlock,
+ inlineCode: InlineCode,
+ blockquote: BlockQuote,
+ ExampleTabs,
+ Code,
+ Implementation,
+ Schema,
+ PackageInstallation,
+ Video,
+ Warning,
+ ApiChoiceTabs,
+ InputChoiceTabs,
+ Tabs,
+ Tab,
+ List,
+ Panel,
+ // Hyphenated aliases for dotted component names (dots invalid in HTML element names)
+ "InputChoiceTabs-CLI": InputChoiceTabs.CLI,
+ "InputChoiceTabs-VisualStudio": InputChoiceTabs.VisualStudio,
+ "ApiChoiceTabs-MinimalApis": ApiChoiceTabs.MinimalApis,
+ "ApiChoiceTabs-Regular": ApiChoiceTabs.Regular,
+ // Lowercase aliases: rehype-raw (used with format:"md") lowercases all HTML
+ // tag names per the HTML spec, so