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