diff --git a/.env.next.example b/.env.next.example new file mode 100644 index 0000000000..a68879945c --- /dev/null +++ b/.env.next.example @@ -0,0 +1,43 @@ +# Next.js Environment Variables +# Copy this file to .env.local and fill in the values + +# Site URL +NEXT_PUBLIC_ABLY_MAIN_WEBSITE=https://ably.com + +# Development API Key (optional, for local testing) +NEXT_PUBLIC_ABLY_KEY= + +# Analytics +NEXT_PUBLIC_GTM_CONTAINER_ID= +NEXT_PUBLIC_HUBSPOT_TRACKING_ID= +NEXT_PUBLIC_MIXPANEL_API_KEY= +NEXT_PUBLIC_MIXPANEL_AUTO_CAPTURE= +NEXT_PUBLIC_POSTHOG_API_KEY= +NEXT_PUBLIC_POSTHOG_API_HOST=https://insights.ably.com +NEXT_PUBLIC_POSTHOG_FEEDBACK_SURVEY_NAME=Docs Feedback +NEXT_PUBLIC_INSIGHTS_ENABLED=false +NEXT_PUBLIC_INSIGHTS_DEBUG=false + +# Search +NEXT_PUBLIC_INKEEP_CHAT_ENABLED=false +NEXT_PUBLIC_INKEEP_SEARCH_ENABLED=false +NEXT_PUBLIC_INKEEP_CHAT_API_KEY= + +# Features +NEXT_PUBLIC_ANNOUNCEMENT_ENABLED=false +NEXT_PUBLIC_HEADWAY_ACCOUNT_ID= + +# Consent Management +NEXT_PUBLIC_ONE_TRUST_ENABLED=false +NEXT_PUBLIC_ONE_TRUST_DOMAIN= +NEXT_PUBLIC_ONE_TRUST_TEST=false + +# Conversations API +NEXT_PUBLIC_CONVERSATIONS_API_URL= + +# Sentry +NEXT_PUBLIC_SENTRY_DSN= +NEXT_PUBLIC_SENTRY_ENVIRONMENT=development +SENTRY_AUTH_TOKEN= +SENTRY_ORG= +SENTRY_PROJECT= diff --git a/.gitignore b/.gitignore index 11c7f8ca85..f6f2d731ba 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,17 @@ tags spec/examples.txt node_modules/ .cache/ -public +.next/ +# Gatsby build output (but allow Next.js static assets) +public/* +!public/fonts/ +!public/images/ +# Ignore copied example images (generated from src/images/examples during build) +public/images/examples/*.png !examples/*/*/public .env.* !.env.example +!.env.next.example graphql-types.ts cli dist diff --git a/NEXTJS_MIGRATION.md b/NEXTJS_MIGRATION.md new file mode 100644 index 0000000000..d2df1ae20a --- /dev/null +++ b/NEXTJS_MIGRATION.md @@ -0,0 +1,236 @@ +# Next.js 14+ App Router Migration Guide + +This document describes the Next.js migration implementation for the Ably documentation site. + +## Files Created + +### Core Configuration + +| File | Purpose | +|------|---------| +| `next.config.mjs` | Next.js configuration with MDX support | +| `tsconfig.next.json` | TypeScript config for Next.js (rename to `tsconfig.json` when switching) | +| `tailwind.next.config.js` | Tailwind config with App Router paths (rename to `tailwind.config.js` when switching) | +| `vercel.json` | Vercel deployment configuration | +| `.env.next.example` | Environment variables template | +| `package.next.json` | Dependencies and scripts reference | + +### App Router Structure + +``` +/app +├── layout.tsx # Root layout with metadata and providers +├── providers.tsx # Client-side providers (UserContext, PostHog, Insights) +├── globals.css # Global styles +├── docs/ +│ ├── layout.tsx # Docs layout wrapper +│ ├── DocsLayoutClient.tsx # Client-side docs layout +│ ├── page.tsx # /docs index (redirects to /docs/getting-started) +│ ├── not-found.tsx # 404 page for docs +│ └── [...slug]/ +│ ├── page.tsx # Dynamic MDX page handler +│ └── MDXPageClient.tsx # Client-side MDX renderer with API key injection +└── examples/ + ├── layout.tsx # Examples layout + ├── page.tsx # Examples list page + ├── ExamplesListPage.tsx # Client-side examples list + └── [id]/ + ├── page.tsx # Individual example page + └── ExamplePageClient.tsx # Client-side example with Sandpack +``` + +### Library Files + +| File | Purpose | +|------|---------| +| `lib/mdx.ts` | MDX utilities (getAllMdxSlugs, getMdxBySlug, extractRedirects) | +| `lib/examples.ts` | Example file loader utilities | +| `lib/user-context.tsx` | Client-side user context with API key fetching | +| `lib/layout-context.tsx` | Layout context for active page/language detection | +| `lib/site-config.ts` | Site configuration (replaces Gatsby siteMetadata) | +| `lib/link.tsx` | Link component adapter (Gatsby→Next.js compatibility) | + +### Build Scripts + +| File | Purpose | +|------|---------| +| `scripts/generate-redirects.ts` | Generate redirects from MDX frontmatter | +| `scripts/generate-llms-txt.ts` | Generate llms.txt for AI/LLM consumption | + +## Migration Steps + +### 1. Install Dependencies + +```bash +# Install new dependencies +npm install next@^14.2.0 @next/mdx@^14.2.0 @mdx-js/loader@^3.0.1 \ + @mdx-js/react@^3.0.1 next-mdx-remote@^4.4.1 gray-matter@^4.0.3 \ + remark-gfm@^4.0.0 @svgr/webpack@^8.1.0 + +# Install dev dependencies +npm install -D @types/node@^20.0.0 @types/react@^18.2.0 @types/react-dom@^18.2.0 \ + eslint-config-next@^14.2.0 npm-run-all@^4.1.5 ts-node@^10.9.2 +``` + +### 2. Update Configuration Files + +```bash +# Rename configs to use Next.js versions +mv tsconfig.json tsconfig.gatsby.json +mv tsconfig.next.json tsconfig.json + +mv tailwind.config.js tailwind.gatsby.config.js +mv tailwind.next.config.js tailwind.config.js +``` + +### 3. Update package.json Scripts + +Replace Gatsby scripts with Next.js scripts from `package.next.json`: + +```json +{ + "scripts": { + "dev": "next dev", + "build": "next build && npm run postbuild", + "start": "next start", + "lint": "next lint", + "postbuild": "npm-run-all generate:*", + "generate:llms": "ts-node scripts/generate-llms-txt.ts", + "generate:redirects": "ts-node scripts/generate-redirects.ts" + } +} +``` + +### 4. Update Environment Variables + +Copy `.env.next.example` to `.env.local` and configure: + +- Replace `GATSBY_*` prefixed vars with `NEXT_PUBLIC_*` +- Update API endpoint URLs if needed + +### 5. Component Migration (Required Updates) + +The following existing components need updates to work with Next.js: + +#### Header.tsx +- Replace `useStaticQuery` + `graphql` with `siteConfig` import +- Replace `useLocation` from `@reach/router` with `usePathname` from `next/navigation` + +#### LeftSidebar.tsx +- Replace `useStaticQuery` + `graphql` with `externalScriptsData` import +- Replace `useLocation` with `usePathname` + +#### Link.tsx +- Replace Gatsby's `Link` with `next/link` (use `lib/link.tsx` adapter) + +#### Footer.tsx +- Update to work with Next.js page context if needed + +#### GlobalLoading.tsx +- Ensure it works with React Suspense boundaries + +### 6. Copy Static Assets + +```bash +# Copy fonts to public directory +mkdir -p public/fonts +cp -r src/fonts/* public/fonts/ + +# Copy example images +mkdir -p public/images/examples +cp src/components/Examples/images/* public/images/examples/ +``` + +### 7. Generate Redirects + +```bash +npx ts-node scripts/generate-redirects.ts +``` + +Then import the generated redirects in `next.config.mjs`: + +```javascript +import redirects from './generated-redirects.json' assert { type: 'json' }; + +const nextConfig = { + // ... + async redirects() { + return redirects; + }, +}; +``` + +### 8. Test the Migration + +```bash +# Start development server +npm run dev + +# Build for production +npm run build + +# Test production build locally +npm start +``` + +## Architecture Notes + +### API Key Injection (Preserved) + +The API key injection flow is preserved exactly as it was: + +1. Page loads as static HTML (SSG via `generateStaticParams`) +2. `UserContextProvider` mounts client-side +3. Fetches session from `/api/me` and API keys from `/api/api_keys` +4. Falls back to demo key from `/ably-auth/api-key/docs` +5. `WrappedCodeSnippet` replaces `{{API_KEY}}` and `{{RANDOM_CHANNEL_NAME}}` at render time + +### Client Components + +The following are marked as `'use client'`: + +- `app/providers.tsx` - All context providers +- `app/docs/DocsLayoutClient.tsx` - Docs layout with navigation +- `app/docs/[...slug]/MDXPageClient.tsx` - MDX content renderer +- `app/examples/ExamplesListPage.tsx` - Examples listing +- `app/examples/[id]/ExamplePageClient.tsx` - Sandpack sandbox +- `lib/user-context.tsx` - User/API key management +- `lib/layout-context.tsx` - Active page detection +- `lib/link.tsx` - Link component + +### Server Components + +These remain as server components: + +- `app/layout.tsx` - Root layout with metadata +- `app/docs/layout.tsx` - Docs layout wrapper +- `app/docs/[...slug]/page.tsx` - MDX page with static generation +- `app/examples/[id]/page.tsx` - Example page with static generation +- MDX content compilation (via `next-mdx-remote/serialize`) + +## Key Differences from Gatsby + +| Feature | Gatsby | Next.js | +|---------|--------|---------| +| Data fetching | GraphQL queries | Direct filesystem reads | +| Routing | File-based in `src/pages` | File-based in `app/` | +| Layout | `gatsby-plugin-layout` | Layout components | +| MDX | `gatsby-plugin-mdx` | `@next/mdx` + `next-mdx-remote` | +| Static generation | `createPages` | `generateStaticParams` | +| Images | `gatsby-plugin-image` | `next/image` | +| Link | Gatsby Link | Next.js Link | +| Head/SEO | `react-helmet` | Metadata API | +| Environment vars | `GATSBY_*` prefix | `NEXT_PUBLIC_*` prefix | + +## Verification Checklist + +- [ ] All MDX pages render correctly +- [ ] Code snippets show API keys when logged in +- [ ] Demo keys appear when logged out +- [ ] Language switching works +- [ ] Navigation sidebar works +- [ ] All examples render with Sandpack +- [ ] Redirects work correctly +- [ ] llms.txt is generated +- [ ] Build completes without errors +- [ ] Lighthouse audit passes diff --git a/app/docs/DocsLayoutClient.tsx b/app/docs/DocsLayoutClient.tsx new file mode 100644 index 0000000000..ba20dd40d4 --- /dev/null +++ b/app/docs/DocsLayoutClient.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { ReactNode } from 'react'; +import cn from '@ably/ui/core/utils/cn'; +import { LayoutProvider } from '@/lib/layout-context'; +import Header from '@/src/components/Layout/Header'; +import LeftSidebar from '@/src/components/Layout/LeftSidebar'; +import RightSidebar from '@/src/components/Layout/RightSidebar'; +import Footer from '@/src/components/Layout/Footer'; +import Breadcrumbs from '@/src/components/Layout/Breadcrumbs'; +import HiddenLanguageLinks from '@/src/components/Layout/HiddenLanguageLinks'; +import GlobalLoading from '@/src/components/GlobalLoading/GlobalLoading'; +import { Container } from '@/src/components/Container'; + +interface DocsLayoutClientProps { + children: ReactNode; + pageLanguages?: string[]; + hideLeftSidebar?: boolean; + hideRightSidebar?: boolean; + template?: string; +} + +export function DocsLayoutClient({ + children, + pageLanguages, + hideLeftSidebar = false, + hideRightSidebar = false, + template = 'mdx', +}: DocsLayoutClientProps) { + const showLeftSidebar = !hideLeftSidebar; + const showRightSidebar = !hideRightSidebar; + + return ( + + +
+
+ +
+
+ + {showLeftSidebar ? :
} + {children} +
+ + {showRightSidebar ? :
} +
+
+
+ + + + ); +} diff --git a/app/docs/[...slug]/MDXPageClient.tsx b/app/docs/[...slug]/MDXPageClient.tsx new file mode 100644 index 0000000000..8e077e0f9a --- /dev/null +++ b/app/docs/[...slug]/MDXPageClient.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { useState, useMemo, useEffect, isValidElement, cloneElement, ReactNode, ReactElement, createContext, useContext, FC } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote'; +import CodeSnippet from '@ably/ui/core/CodeSnippet'; +import type { CodeSnippetProps, SDKType } from '@ably/ui/core/CodeSnippet'; +import cn from '@ably/ui/core/utils/cn'; + +import { useUser, getApiKeysForCodeSnippet } from '@/lib/user-context'; +import { useLayoutContext, Frontmatter } from '@/lib/layout-context'; +import { useCopyableHeaders } from '@/src/components/Layout/mdx/headers'; +import { getRandomChannelName } from '@/src/utilities/get-random-channel-name'; +import { FrontmatterData } from '@/lib/mdx'; +import { languageData } from '@/src/data/languages'; + +import If from '@/src/components/Layout/mdx/If'; +import Table from '@/src/components/Layout/mdx/Table'; +import { Tiles } from '@/src/components/Layout/mdx/tiles'; +import { PageHeader } from '@/src/components/Layout/mdx/PageHeader'; +import Admonition from '@/src/components/Layout/mdx/Admonition'; +import Article from '@/src/components/Article'; +import Link from '@/src/components/Link'; +import { CodeBlock } from '@/src/components/Markdown/CodeBlock'; +import { checkLinkIsInternal } from '@/src/utilities/link-checks'; + +// SDK Context for code snippets +type SDKContextType = { + sdk: SDKType; + setSdk: (sdk: SDKType) => void; +}; + +const SDKContext = createContext(undefined); + +const useSDK = () => { + const context = useContext(SDKContext); + if (!context) { + throw new Error('useSDK must be used within an SDKProvider'); + } + return context; +}; + +type Replacement = { + term: string; + replacer: () => string; +}; + +type ElementProps = { className?: string; children?: ReactNode }; + +// Wrapped CodeSnippet with API key injection +interface WrappedCodeSnippetProps extends CodeSnippetProps { + apiKeys: ReturnType; +} + +const WrappedCodeSnippet: React.FC = ({ + apiKeys, + children, + ...props +}) => { + const { sdk, setSdk } = useSDK(); + const { activePage } = useLayoutContext(); + const router = useRouter(); + + const replacements: Replacement[] = useMemo( + () => [{ term: 'RANDOM_CHANNEL_NAME', replacer: getRandomChannelName }], + [], + ); + + const processedChildren = useMemo(() => { + const replaceInString = (str: string) => { + let result = str; + replacements.forEach(({ term, replacer }) => { + const regex = new RegExp(`{{${term}}}`, 'g'); + result = result.replace(regex, replacer()); + }); + return result; + }; + + const processChild = (child: ReactNode, index?: number): ReactNode => { + if (typeof child === 'string') { + return replaceInString(child); + } + if (Array.isArray(child)) { + return child.map((c, i) => processChild(c, i)); + } + if (isValidElement(child)) { + const element = child as ReactElement<{ children?: ReactNode; key?: string | number }>; + const key = element.key ?? index; + return cloneElement(element, { ...element.props, key }, processChild(element.props.children)); + } + return child; + }; + + return processChild(children); + }, [children, replacements]); + + // Check if this code block contains only a single utility language + const utilityLanguageOverride = useMemo(() => { + const UTILITY_LANGUAGES = ['html', 'xml', 'css', 'sql', 'json']; + const childrenArray = Array.isArray(processedChildren) ? processedChildren : [processedChildren]; + + if (childrenArray.length !== 1) { + return null; + } + + const child = childrenArray[0]; + if (!isValidElement(child)) { + return null; + } + + const preElement = child as ReactElement; + const codeElement = isValidElement(preElement.props?.children) + ? (preElement.props.children as ReactElement) + : null; + + if (!codeElement || !codeElement.props?.className) { + return null; + } + + const className = codeElement.props.className as string; + const langMatch = className.match(/language-(\w+)/); + const lang = langMatch ? langMatch[1] : null; + + return lang && UTILITY_LANGUAGES.includes(lang) ? lang : null; + }, [processedChildren]); + + return ( + { + setSdk(newSdk ?? null); + router.push(`${window.location.pathname}?lang=${lang}`, { scroll: false }); + }} + className={cn(props.className, 'mb-5')} + languageOrdering={ + activePage.product && languageData[activePage.product] + ? Object.keys(languageData[activePage.product]) + : [] + } + apiKeys={apiKeys} + > + {processedChildren} + + ); +}; + +// Styled HTML element components for MDX +const H1: FC = ({ children, ...props }) => ( +

+ {children} +

+); + +const H2: FC = ({ children, ...props }) => ( +

+ {children} +

+); + +const H3: FC = ({ children, ...props }) => ( +

+ {children} +

+); + +const H4: FC = ({ children, ...props }) => ( +

+ {children} +

+); + +const H5: FC = ({ children, ...props }) => ( +
+ {children} +
+); + +const Paragraph: FC = ({ children, ...props }) => ( +

+ {children} +

+); + +const Ol: FC = ({ children, ...props }) => ( +
    + {children} +
+); + +const Ul: FC = ({ children, ...props }) => ( +
    + {children} +
+); + +const Li: FC = ({ children, ...props }) => ( +
  • + {children} +
  • +); + +const InlineCode: FC = ({ children, ...props }) => ( + + {children} + +); + +const Pre: FC = ({ children }) => { + const lang = (children as React.ReactElement)?.props?.className?.replace('language-', ''); + + return ( +
    + {children} +
    + ); +}; + +const Anchor: FC = ({ children, href, ...props }) => { + const searchParams = useSearchParams(); + + let cleanHref = href; + + // Add lang param from current URL if available + const langParam = searchParams.get('lang'); + + if (langParam && cleanHref && checkLinkIsInternal(cleanHref)) { + const url = new URL(cleanHref, 'https://ably.com'); + url.searchParams.set('lang', langParam); + cleanHref = url.pathname + url.search; + } + + return ( + + {children} + + ); +}; + +interface MDXPageClientProps { + mdxSource: MDXRemoteSerializeResult; + frontmatter: FrontmatterData; + slug: string; +} + +export function MDXPageClient({ mdxSource, frontmatter, slug }: MDXPageClientProps) { + const { activePage, setPageContext } = useLayoutContext(); + const userContext = useUser(); + + const [sdk, setSdk] = useState(() => { + // Determine initial SDK from page languages + const pageLanguages = frontmatter.languages || []; + const sdkLanguage = pageLanguages + .filter((language: string) => language.startsWith('realtime') || language.startsWith('rest')) + .find((language: string) => activePage.language && language.endsWith(activePage.language)); + return (sdkLanguage?.split('_')[0] as SDKType) ?? null; + }); + + // Set page context with frontmatter for Footer and other components + useEffect(() => { + setPageContext({ + frontmatter: frontmatter as Frontmatter, + }); + return () => { + setPageContext({}); + }; + }, [frontmatter, setPageContext]); + + // Use copyable headers hook + useCopyableHeaders(); + + // Get API keys for code snippets + const apiKeys = useMemo(() => getApiKeysForCodeSnippet(userContext), [userContext]); + + const title = frontmatter.title || ''; + const intro = frontmatter.intro || ''; + + // MDX components with API key injection and styled HTML elements + const mdxComponents = useMemo( + () => ({ + // Custom components + If, + Code: (props: CodeSnippetProps) => ( + + ), + Aside: Admonition, + Table, + table: Table.Root, + thead: Table.Header, + tbody: Table.Body, + tr: Table.Row, + th: Table.Head, + td: Table.Cell, + Tiles, + // Styled HTML elements + h1: H1, + h2: H2, + h3: H3, + h4: H4, + h5: H5, + p: Paragraph, + a: Anchor, + ol: Ol, + ul: Ul, + li: Li, + code: InlineCode, + pre: Pre, + }), + [apiKeys], + ); + + return ( + +
    + + +
    +
    + ); +} diff --git a/app/docs/[...slug]/page.tsx b/app/docs/[...slug]/page.tsx new file mode 100644 index 0000000000..e581fef427 --- /dev/null +++ b/app/docs/[...slug]/page.tsx @@ -0,0 +1,82 @@ +import { notFound } from 'next/navigation'; +import { Metadata } from 'next'; +import { serialize } from 'next-mdx-remote/serialize'; +import remarkGfm from 'remark-gfm'; +import { getAllMdxSlugs, getMdxBySlug } from '@/lib/mdx'; +import { canonicalUrl, META_DESCRIPTION_FALLBACK } from '@/lib/site-config'; +import { MDXPageClient } from './MDXPageClient'; + +interface DocPageProps { + params: Promise<{ slug: string[] }>; +} + +// Generate static params for all MDX pages +export async function generateStaticParams() { + const slugs = await getAllMdxSlugs(); + + return slugs.map((slug) => ({ + slug: slug.split('/'), + })); +} + +// Generate metadata for each page +export async function generateMetadata({ params }: DocPageProps): Promise { + const resolvedParams = await params; + const slug = resolvedParams.slug.join('/'); + const mdxData = await getMdxBySlug(slug); + + if (!mdxData) { + return { + title: 'Not Found', + }; + } + + const { frontmatter } = mdxData; + const title = frontmatter.title || 'Documentation'; + const description = frontmatter.meta_description || META_DESCRIPTION_FALLBACK; + const canonical = canonicalUrl(`/docs/${slug}`); + + return { + title, + description, + keywords: frontmatter.meta_keywords, + alternates: { + canonical, + }, + openGraph: { + title, + description, + url: canonical, + type: 'article', + }, + }; +} + +export default async function DocPage({ params }: DocPageProps) { + const resolvedParams = await params; + const slug = resolvedParams.slug.join('/'); + const mdxData = await getMdxBySlug(slug); + + if (!mdxData) { + notFound(); + } + + const { frontmatter, content } = mdxData; + + // Serialize MDX content on the server + const mdxSource = await serialize(content, { + mdxOptions: { + remarkPlugins: [remarkGfm], + development: process.env.NODE_ENV === 'development', + }, + parseFrontmatter: false, // Already parsed via gray-matter + }); + + return ( + + ); +} diff --git a/app/docs/layout.tsx b/app/docs/layout.tsx new file mode 100644 index 0000000000..5dc630e8eb --- /dev/null +++ b/app/docs/layout.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react'; +import { DocsLayoutClient } from './DocsLayoutClient'; + +export default function DocsLayout({ children }: { children: React.ReactNode }) { + return ( + }> + {children} + + ); +} diff --git a/app/docs/not-found.tsx b/app/docs/not-found.tsx new file mode 100644 index 0000000000..0205ab9fc1 --- /dev/null +++ b/app/docs/not-found.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link'; + +export default function DocsNotFound() { + return ( +
    +

    Page Not Found

    +

    + The documentation page you're looking for doesn't exist or may have been moved. +

    +
    + + Browse Documentation + + + View Examples + +
    +
    + ); +} diff --git a/app/docs/page.tsx b/app/docs/page.tsx new file mode 100644 index 0000000000..04d027af63 --- /dev/null +++ b/app/docs/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation'; + +// Redirect /docs to the first documentation page +export default function DocsIndexPage() { + // Redirect to the getting started page + redirect('/docs/getting-started'); +} diff --git a/app/examples/ExamplesListPage.tsx b/app/examples/ExamplesListPage.tsx new file mode 100644 index 0000000000..5726732e6d --- /dev/null +++ b/app/examples/ExamplesListPage.tsx @@ -0,0 +1,105 @@ +'use client'; + +import React, { ChangeEvent, useCallback, useEffect, useState, useMemo } from 'react'; +import Image from 'next/image'; +import ExamplesGrid from '@/src/components/Examples/ExamplesGrid'; +import ExamplesFilter from '@/src/components/Examples/ExamplesFilter'; +import { examples } from '@/src/data/examples/'; +import { filterSearchExamples } from '@/src/components/Examples/filter-search-examples'; +import ExamplesNoResults from '@/src/components/Examples/ExamplesNoResults'; +import { ProductName, products as dataProducts } from '@ably/ui/core/ProductTile/data'; +import { useSearchParams } from 'next/navigation'; +import { ImageProps } from '@/src/components/Image'; + +export type SelectedFilters = { products: ProductName[]; useCases: string[] }; + +export function ExamplesListPage() { + const searchParams = useSearchParams(); + + // Generate image props from example IDs + const exampleImages: ImageProps[] = useMemo(() => { + return examples.map((example) => ({ + name: example.id, + src: `/images/examples/${example.id}.png`, + width: 400, + height: 300, + })); + }, []); + + // Parse product query parameters and filter for valid ProductName values + const getInitialProducts = (): ProductName[] => { + const productParam = searchParams?.get('product'); + const validProductNames = Object.keys(dataProducts).map((product) => product.toLowerCase()); + + if (!productParam) { + return []; + } + + // Split comma-separated products and filter only valid ProductName values + return productParam + .split(',') + .map((p) => p.trim()) + .filter((product): product is ProductName => + validProductNames.includes(product as string), + ) as ProductName[]; + }; + + const [selected, setSelected] = useState({ + products: getInitialProducts(), + useCases: [], + }); + const [searchTerm, setSearchTerm] = useState(''); + const [filteredExamples, setFilteredExamples] = useState(examples); + + const handleSearch = useCallback((e: ChangeEvent) => { + setSearchTerm(e.target.value); + }, []); + + useEffect(() => { + const filteredExamples = filterSearchExamples(examples, selected, searchTerm); + setFilteredExamples(filteredExamples); + }, [selected, searchTerm]); + + return ( + <> +
    +
    +

    Examples

    +

    + From avatar stacks to live cursors, learn how deliver live chat, multiplayer collaboration features, and + more. +

    +
    +
    +
    + +
    +
    + {filteredExamples.length > 0 ? ( + + ) : ( + + )} +
    +
    +
    + + {/* Background pattern images */} + Grid Pattern + + Grid Pattern + + ); +} diff --git a/app/examples/[id]/ExamplePageClient.tsx b/app/examples/[id]/ExamplePageClient.tsx new file mode 100644 index 0000000000..683f9c2811 --- /dev/null +++ b/app/examples/[id]/ExamplePageClient.tsx @@ -0,0 +1,199 @@ +'use client'; + +import React, { PropsWithChildren, useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Markdown from 'markdown-to-jsx'; +import Icon from '@ably/ui/core/Icon'; +import LinkButton from '@ably/ui/core/LinkButton'; +import { UnstyledOpenInCodeSandboxButton } from '@codesandbox/sandpack-react'; + +import { ExampleWithContent } from '@/src/data/examples/types'; +import { useUser, getApiKey } from '@/lib/user-context'; +import ExamplesRenderer from '@/src/components/Examples/ExamplesRenderer'; +import { LanguageKey } from '@/src/data/languages/types'; + +const MarkdownOverrides = { + h1: { + component: ({ children }: PropsWithChildren) =>

    {children}

    , + }, + h2: { + component: ({ children }: PropsWithChildren) =>

    {children}

    , + }, + h3: { + component: ({ children }: PropsWithChildren) =>

    {children}

    , + }, + p: { + component: ({ children }: PropsWithChildren) =>

    {children}

    , + }, + ul: { + component: ({ children }: PropsWithChildren) =>
      {children}
    , + }, + ol: { + component: ({ children }: PropsWithChildren) => ( +
      {children}
    + ), + }, + pre: { + component: ({ children }: PropsWithChildren) => ( +
    +        {children}
    +      
    + ), + }, + code: { + component: ({ children }: PropsWithChildren) => {children}, + }, + a: { + component: ({ children, href }: PropsWithChildren<{ href: string }>) => ( + + {children} + + ), + }, + table: { + component: ({ children }: PropsWithChildren) => ( +
    + {children}
    +
    + ), + }, + thead: { + component: ({ children }: PropsWithChildren) => {children}, + }, + tbody: { + component: ({ children }: PropsWithChildren) => {children}, + }, + tr: { + component: ({ children }: PropsWithChildren) => ( + {children} + ), + }, + th: { + component: ({ children }: PropsWithChildren) => ( + + {children} + + ), + }, + td: { + component: ({ children }: PropsWithChildren) => {children}, + }, +}; + +interface ExamplePageClientProps { + example: ExampleWithContent; +} + +export function ExamplePageClient({ example }: ExamplePageClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const userData = useUser(); + const apiKey = getApiKey(userData, true); + + const isFirstRender = useRef(true); + const [activeLanguage, setActiveLanguage] = useState(() => { + // Get all available language keys from the example + const languageKeys = Object.keys(example.files) as LanguageKey[]; + + // Check if we have a language in the URL query parameters + const langParam = searchParams.get('lang') as LanguageKey | null; + + // If the lang parameter exists and is a valid language for this example, use it + if (langParam && languageKeys.includes(langParam)) { + return langParam; + } + + // Otherwise, default to the first available language + return languageKeys[0]; + }); + + useEffect(() => { + // Update URL with the active language + const newParams = new URLSearchParams(searchParams.toString()); + newParams.set('lang', activeLanguage); + router.replace(`/examples/${example.id}?${newParams.toString()}`, { scroll: false }); + + // There is a bug in Sandpack where startRoute is lost on re-renders. + // This is a workaround to reintroduce it post-language change. + if (example.id === 'pub-sub-message-encryption' || example.id === 'pub-sub-message-annotations') { + if (isFirstRender.current) { + isFirstRender.current = false; + } else { + setTimeout(() => { + const iframe = document.querySelector('.sp-preview-iframe') as HTMLIFrameElement; + if (iframe) { + let queryParams = 'encrypted=true'; // for pub-sub-message-encryption + if (example.id === 'pub-sub-message-annotations') { + queryParams = 'clientId=user1'; + } + const currentSrc = iframe.getAttribute('src'); + if (currentSrc) { + iframe.setAttribute('src', currentSrc + '?' + queryParams); + } + } + }, 100); + } + } + }, [activeLanguage, example.id, router, searchParams]); + + const content = React.useMemo(() => example.files[activeLanguage]?.['README.md'], [example.files, activeLanguage]); + + if (!apiKey) { + return ( +
    +

    Loading...

    +
    + ); + } + + return ( + <> +
    + + + All examples + +

    {example.name}

    +

    {example.description}

    +
    + + +
    + {content ? ( +
    + + {content} + +
    + ) : null} +
    + + View on GitHub + + + + View on CodeSandbox + + +
    +
    +
    + + ); +} diff --git a/app/examples/[id]/page.tsx b/app/examples/[id]/page.tsx new file mode 100644 index 0000000000..d39545bcda --- /dev/null +++ b/app/examples/[id]/page.tsx @@ -0,0 +1,56 @@ +import { notFound } from 'next/navigation'; +import { Metadata } from 'next'; +import { getAllExampleIds, getExampleWithContent } from '@/lib/examples'; +import { canonicalUrl } from '@/lib/site-config'; +import { ExamplePageClient } from './ExamplePageClient'; + +interface ExamplePageProps { + params: Promise<{ id: string }>; +} + +// Generate static params for all examples +export async function generateStaticParams() { + const ids = getAllExampleIds(); + return ids.map((id) => ({ id })); +} + +// Generate metadata for each example +export async function generateMetadata({ params }: ExamplePageProps): Promise { + const resolvedParams = await params; + const example = getExampleWithContent(resolvedParams.id); + + if (!example) { + return { + title: 'Not Found', + }; + } + + const title = example.metaTitle || `Ably Examples | ${example.name}`; + const description = example.metaDescription || example.description; + const canonical = canonicalUrl(`/examples/${example.id}`); + + return { + title, + description, + alternates: { + canonical, + }, + openGraph: { + title, + description, + url: canonical, + type: 'article', + }, + }; +} + +export default async function ExamplePage({ params }: ExamplePageProps) { + const resolvedParams = await params; + const example = getExampleWithContent(resolvedParams.id); + + if (!example) { + notFound(); + } + + return ; +} diff --git a/app/examples/layout.tsx b/app/examples/layout.tsx new file mode 100644 index 0000000000..04de34dd16 --- /dev/null +++ b/app/examples/layout.tsx @@ -0,0 +1,16 @@ +import { Suspense } from 'react'; +import Header from '@/src/components/Layout/Header'; +import GlobalLoading from '@/src/components/GlobalLoading/GlobalLoading'; + +export default function ExamplesLayout({ children }: { children: React.ReactNode }) { + return ( + }> + +
    +
    + {children} +
    + + + ); +} diff --git a/app/examples/page.tsx b/app/examples/page.tsx new file mode 100644 index 0000000000..44a646d46a --- /dev/null +++ b/app/examples/page.tsx @@ -0,0 +1,15 @@ +import { Metadata } from 'next'; +import { canonicalUrl } from '@/lib/site-config'; +import { ExamplesListPage } from './ExamplesListPage'; + +export const metadata: Metadata = { + title: 'Examples', + description: 'Browse interactive code examples for Ably Pub/Sub, Chat, Spaces, LiveObjects, and AI Transport.', + alternates: { + canonical: canonicalUrl('/examples'), + }, +}; + +export default function ExamplesPage() { + return ; +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000000..dc24012dfa --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,32 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import { useEffect } from 'react'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + +
    +

    Something went wrong!

    + +
    + + + ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000000..183a1cda1a --- /dev/null +++ b/app/globals.css @@ -0,0 +1,98 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; + +@import '@ably/ui/reset/styles.css'; +@import '@ably/ui/core/styles.css'; +@import '@ably/ui/core/CookieMessage/component.css'; +@import '@ably/ui/core/Slider/component.css'; +@import '@ably/ui/core/Code/component.css'; +@import '@ably/ui/core/Flash/component.css'; +@import '@ably/ui/core/utils/syntax-highlighter.css'; + +:root { + --top-nav-height: 64px; +} + +@layer base { + html { + line-height: unset; + } + + * { + scroll-margin-top: 8.875rem; + } + + @media only screen and (max-width: 1040px) { + * { + scroll-margin-top: 12rem; + } + } + + body { + font-family: + Manrope, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + Roboto, + Helvetica Neue, + Arial, + Noto Sans, + sans-serif, + Apple Color Emoji, + Segoe UI, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; + margin: 0; + font-size: 16px; + color: #161616; + -webkit-font-smoothing: antialiased; + min-width: 320px; + } + + code { + font-family: inherit; + font-weight: inherit; + } + + /* inline code blocks inside anchor tags should have the colour of the anchor tag */ + a > code.ui-text-code-inline { + color: inherit; + } + + #headway-widget-target .HW_badge_cont { + position: absolute; + right: -30px; + top: 21px; + } + + p + .copy-link-identifier:has(h2) { + padding-top: 8px; + } + + @font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/jetbrains-mono/400/webfont.woff2') format('woff2'); + } + + @font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/jetbrains-mono/700/webfont.woff2') format('woff2'); + } + + @font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains-mono/500/webfont.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000..39fbbfd62d --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,102 @@ +import type { Metadata, Viewport } from 'next'; +import { siteConfig, externalScriptsData } from '@/lib/site-config'; +import { Providers } from './providers'; +import './globals.css'; + +export const metadata: Metadata = { + title: { + default: siteConfig.title, + template: '%s | Ably Docs', + }, + description: siteConfig.description, + metadataBase: new URL(siteConfig.siteUrl), + openGraph: { + title: siteConfig.title, + description: siteConfig.description, + url: siteConfig.siteUrl, + siteName: 'Ably Documentation', + locale: 'en_US', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: siteConfig.title, + description: siteConfig.description, + }, + robots: { + index: true, + follow: true, + }, +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + minimumScale: 1, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {/* Google Fonts */} + + + + + {/* OneTrust consent management */} + {externalScriptsData.oneTrustEnabled === 'true' && externalScriptsData.oneTrustDomain && ( + <> +