diff --git a/.github/workflows/sync-content-cms.yml b/.github/workflows/sync-content-cms.yml new file mode 100644 index 000000000..aad6e3f35 --- /dev/null +++ b/.github/workflows/sync-content-cms.yml @@ -0,0 +1,231 @@ +# GitHub Actions Workflow: Sync Content to Strapi CMS (With Relations Support) +# This workflow syncs content changes from specific data folders to Strapi CMS +# Supports foreign key relations: tags, authors, related_faqs +# Triggers: On labeled PR, on push to staging/main branches + +name: Sync Content to Strapi CMS + +on: + pull_request: + types: [labeled, synchronize, opened, reopened] + paths: + - 'data/**/*.mdx' + - 'data/**/*.md' + push: + branches: + - test/cms-with-isr # Configurable: Change to your production branch + - staging # Configurable: Change to your staging branch + paths: + - 'data/**/*.mdx' + - 'data/**/*.md' + +env: + # Configurable: Array of folders to sync to CMS + SYNC_FOLDERS: '["faqs", "case-study", "comparisons", "guides"]' + + # Strapi Configuration + CMS_API_URL: ${{ secrets.CMS_API_URL }} + CMS_API_TOKEN: ${{ secrets.CMS_API_TOKEN }} + + # Revalidation Configuration + NEXT_PUBLIC_BASE_URL: ${{ secrets.NEXT_PUBLIC_BASE_URL }} + NEXT_PUBLIC_STAGING_BASE_URL: ${{ secrets.NEXT_PUBLIC_STAGING_BASE_URL }} + REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }} + + # Branch Configuration + PRODUCTION_BRANCH: 'test/cms-with-isr' + STAGING_BRANCH: 'test/cms-with-isr' + +jobs: + detect-changes: + name: Detect Changed Files + runs-on: ubuntu-latest + outputs: + changed_files: ${{ steps.changed-files.outputs.all_changed_files }} + deleted_files: ${{ steps.changed-files.outputs.deleted_files }} + any_changed: ${{ steps.changed-files.outputs.any_changed }} + any_deleted: ${{ steps.changed-files.outputs.any_deleted }} + deployment_status: ${{ steps.determine-status.outputs.deployment_status }} + should_sync: ${{ steps.check-sync.outputs.should_sync }} + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get Changed Files + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files: | + data/**/*.mdx + data/**/*.md + json: true + escape_json: false + + - name: Check if Sync is Required + id: check-sync + run: | + echo "Checking if sync should run..." + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'staging') }}" == "true" ]]; then + echo "should_sync=true" >> $GITHUB_OUTPUT + echo "✅ Sync enabled: PR has 'staging' label" + else + echo "should_sync=false" >> $GITHUB_OUTPUT + echo "⏭️ Sync skipped: PR does not have 'staging' label" + fi + else + echo "should_sync=true" >> $GITHUB_OUTPUT + echo "✅ Sync enabled: Push event to branch" + fi + + - name: Determine Deployment Status + id: determine-status + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'staging') }}" == "true" ]]; then + echo "deployment_status=staging" >> $GITHUB_OUTPUT + echo "📦 Deployment Status: staging" + else + echo "deployment_status=draft" >> $GITHUB_OUTPUT + echo "📦 Deployment Status: draft" + fi + elif [[ "${{ github.ref }}" == "refs/heads/${{ env.PRODUCTION_BRANCH }}" ]]; then + echo "deployment_status=live" >> $GITHUB_OUTPUT + echo "📦 Deployment Status: live" + elif [[ "${{ github.ref }}" == "refs/heads/${{ env.STAGING_BRANCH }}" ]]; then + echo "deployment_status=staging" >> $GITHUB_OUTPUT + echo "📦 Deployment Status: staging" + else + echo "deployment_status=draft" >> $GITHUB_OUTPUT + echo "📦 Deployment Status: draft" + fi + + - name: Display Changed Files + if: steps.changed-files.outputs.any_changed == 'true' + run: | + echo "📝 Changed files detected:" + echo '${{ steps.changed-files.outputs.all_changed_files }}' | jq -r '.[]' + + - name: Display Deleted Files + if: steps.changed-files.outputs.any_deleted == 'true' + run: | + echo "🗑️ Deleted files detected:" + echo '${{ steps.changed-files.outputs.deleted_files }}' | jq -r '.[]' + + sync-to-cms: + name: Sync Content to CMS + runs-on: ubuntu-latest + needs: detect-changes + if: (needs.detect-changes.outputs.any_changed == 'true' || needs.detect-changes.outputs.any_deleted == 'true') && needs.detect-changes.outputs.should_sync == 'true' + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install Dependencies + run: | + npm install --no-save \ + gray-matter \ + axios \ + js-yaml + + - name: Sync Content to Strapi + id: sync + env: + CHANGED_FILES: ${{ needs.detect-changes.outputs.changed_files }} + DELETED_FILES: ${{ needs.detect-changes.outputs.deleted_files }} + DEPLOYMENT_STATUS: ${{ needs.detect-changes.outputs.deployment_status }} + run: node scripts/sync-content-to-strapi.js + + - name: Trigger Revalidation + if: success() + env: + DEPLOYMENT_STATUS: ${{ needs.detect-changes.outputs.deployment_status }} + run: | + echo "🔄 Triggering ISR revalidation..." + echo "Environment: $DEPLOYMENT_STATUS" + + # Determine the base URL based on deployment status + if [ "$DEPLOYMENT_STATUS" = "live" ]; then + BASE_URL="${{ env.NEXT_PUBLIC_BASE_URL }}" + echo "Using production URL: $BASE_URL" + elif [ "$DEPLOYMENT_STATUS" = "staging" ]; then + BASE_URL="${{ env.NEXT_PUBLIC_STAGING_BASE_URL }}" + echo "Using staging URL: $BASE_URL" + else + echo "⏭️ Skipping revalidation for draft deployment" + exit 0 + fi + + RESPONSE=$(curl -s -w "\n%{http_code}" --location "$BASE_URL/api/revalidate" \ + --header 'Content-Type: application/json' \ + --data "{ + \"revalidateAll\": true, + \"clearCache\": true, + \"secret\": \"${{ env.REVALIDATE_SECRET }}\" + }") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | head -n-1) + + if [ "$HTTP_CODE" -eq 200 ]; then + echo "✅ Revalidation successful!" + echo "Response: $BODY" + else + echo "❌ Revalidation failed with HTTP $HTTP_CODE" + echo "Response: $BODY" + exit 1 + fi + + - name: Update PR Comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + JOB_STATUS: ${{ job.status }} + DEPLOYMENT_STATUS: ${{ needs.detect-changes.outputs.deployment_status }} + with: + script: | + const script = require('${{ github.workspace }}/scripts/update-pr-comment.js'); + await script({github, context, core}); + + report-status: + name: Report Status + runs-on: ubuntu-latest + needs: [detect-changes, sync-to-cms] + if: always() + + steps: + - name: Report Success + if: needs.sync-to-cms.result == 'success' + run: | + echo "✅ Workflow completed successfully!" + echo "Deployment Status: ${{ needs.detect-changes.outputs.deployment_status }}" + + - name: Report Failure + if: needs.sync-to-cms.result == 'failure' + run: | + echo "❌ Workflow failed!" + echo "Please check the logs above for detailed error messages." + exit 1 + + - name: Report Skipped + if: needs.detect-changes.outputs.should_sync != 'true' || (needs.detect-changes.outputs.any_changed != 'true' && needs.detect-changes.outputs.any_deleted != 'true') + run: | + echo "⏭️ Sync was skipped." + if [[ "${{ needs.detect-changes.outputs.any_changed }}" != "true" && "${{ needs.detect-changes.outputs.any_deleted }}" != "true" ]]; then + echo "Reason: No content files changed or deleted" + else + echo "Reason: PR does not have 'staging' label" + fi diff --git a/.gitignore b/.gitignore index 5eb4cb024..09e2415a0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ app/tag-data.json # Interlinking data files (large, not for VCS) interlinking.csv.bak scripts/data/interlinking.csv + +# Sync results (temporary file for CI/CD) +sync-results.json diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index 9f7806d83..75b225d1a 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -1,35 +1,196 @@ -import { revalidateTag } from 'next/cache' + import { NextRequest, NextResponse } from 'next/server' +import { revalidatePath, revalidateTag } from 'next/cache' +import { clearPathsCache } from '@/utils/strapi' -const REVALIDATE_SECRET = process.env.REVALIDATE_SECRET +interface RevalidationResult { + path?: string + tag?: string + revalidated: boolean + type: 'route' | 'path' | 'tag' + timestamp: string +} export async function POST(request: NextRequest) { try { - const requestData = await request.json() - const { tag, secret } = requestData + const body = await request.json() + const { + paths, + path, + tags, + tag, + secret, + revalidateAll = false, + clearCache = false + } = body + + if (secret !== process.env.REVALIDATE_SECRET) { + return NextResponse.json( + { message: 'Invalid secret' }, + { status: 401 } + ) + } + + const results: RevalidationResult[] = [] + + if (clearCache) { + clearPathsCache() + console.log('Cleared paths cache') + } + + if (revalidateAll) { + revalidatePath('/', 'layout') + revalidateTag('mdx-content-list') + revalidateTag('mdx-paths') + + results.push({ + path: '/', + revalidated: true, + type: 'route', + timestamp: new Date().toISOString(), + }) + } + + if (path) { + revalidatePath(path) + revalidateTag(`mdx-content-${path}`) + + results.push({ + path, + revalidated: true, + type: 'path', + timestamp: new Date().toISOString(), + }) + } + + if (paths && Array.isArray(paths)) { + for (const p of paths) { + revalidatePath(p) + revalidateTag(`mdx-content-${p}`) + + results.push({ + path: p, + revalidated: true, + type: 'path', + timestamp: new Date().toISOString(), + }) + } + } + + if (tag) { + revalidateTag(tag) - // Check for secret if configured - if (REVALIDATE_SECRET && secret !== REVALIDATE_SECRET) { - return NextResponse.json({ message: 'Invalid revalidation token' }, { status: 401 }) + results.push({ + tag, + revalidated: true, + type: 'tag', + timestamp: new Date().toISOString(), + }) } - // Verify tag is provided - if (!tag) { - return NextResponse.json({ message: 'Missing tag parameter' }, { status: 400 }) + if (tags && Array.isArray(tags)) { + for (const t of tags) { + revalidateTag(t) + + results.push({ + tag: t, + revalidated: true, + type: 'tag', + timestamp: new Date().toISOString(), + }) + } } - // Revalidate the tag - revalidateTag(tag) + console.log('Revalidation completed:', results) + + return NextResponse.json({ + revalidated: true, + results, + timestamp: new Date().toISOString(), + }) + } catch (error) { + console.error('Revalidation error:', error) + return NextResponse.json( + { + message: 'Error revalidating paths', + error: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const path = searchParams.get('path') + const tag = searchParams.get('tag') + const secret = searchParams.get('secret') + const revalidateAll = searchParams.get('revalidateAll') === 'true' + const clearCache = searchParams.get('clearCache') === 'true' + + if (secret !== process.env.REVALIDATE_SECRET) { + return NextResponse.json( + { message: 'Invalid secret' }, + { status: 401 } + ) + } + + try { + const results: RevalidationResult[] = [] + + if (clearCache) { + clearPathsCache() + console.log('Cleared paths cache') + } + + if (revalidateAll) { + revalidatePath('/', 'layout') + revalidateTag('mdx-content-list') + revalidateTag('mdx-paths') + + results.push({ + path: '/', + revalidated: true, + type: 'route', + timestamp: new Date().toISOString(), + }) + } + + if (path) { + revalidatePath(path) + revalidateTag(`mdx-content-${path}`) + + results.push({ + path, + revalidated: true, + type: 'path', + timestamp: new Date().toISOString(), + }) + } + + if (tag) { + revalidateTag(tag) + + results.push({ + tag, + revalidated: true, + type: 'tag', + timestamp: new Date().toISOString(), + }) + } return NextResponse.json({ revalidated: true, - message: `Tag "${tag}" revalidated successfully`, - timestamp: Date.now(), + results, + timestamp: new Date().toISOString(), }) } catch (error) { console.error('Revalidation error:', error) return NextResponse.json( - { message: 'Error processing revalidation request', error: String(error) }, + { + message: 'Error revalidating paths', + error: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 } ) } diff --git a/app/case-study/[...slug]/page.tsx b/app/case-study/[...slug]/page.tsx index 12dfa3940..e3a8116a4 100644 --- a/app/case-study/[...slug]/page.tsx +++ b/app/case-study/[...slug]/page.tsx @@ -1,65 +1,230 @@ import 'css/prism.css' - +import 'css/tailwind.css' +import 'css/post.css' +import 'css/global.css' +import 'css/doc.css' import { components } from '@/components/MDXComponents' -import { MDXLayoutRenderer } from 'pliny/mdx-components' -import { sortPosts, coreContent, allCoreContent } from 'pliny/utils/contentlayer' -import { allCaseStudies } from 'contentlayer/generated' +import CaseStudyLayout from '../../../layouts/CaseStudyLayout' import { Metadata } from 'next' import siteMetadata from '@/data/siteMetadata' import { notFound } from 'next/navigation' -import { CaseStudy } from '../../../.contentlayer/generated' -import React from 'react' -import CaseStudyLayout from '../../../layouts/CaseStudyLayout' +import { fetchMDXContentByPath, MDXContent } from '@/utils/strapi' +import { compileMDX } from 'next-mdx-remote/rsc' +import readingTime from 'reading-time' +import GithubSlugger from 'github-slugger' +import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic' +import { CoreContent } from 'pliny/utils/contentlayer' +// import { CaseStudy } from '../../../.contentlayer/generated' + +// Remark and rehype plugins +import remarkGfm from 'remark-gfm' +import { + remarkExtractFrontmatter, + remarkCodeTitles, + remarkImgToJsx, +} from 'pliny/mdx-plugins/index.js' +import rehypeSlug from 'rehype-slug' +import rehypeAutolinkHeadings from 'rehype-autolink-headings' +import rehypePrismPlus from 'rehype-prism-plus' +import remarkMath from 'remark-math' + +export const revalidate = 0 +export const dynamicParams = true + +// Heroicon mini link for auto-linking headers +const linkIcon = fromHtmlIsomorphic( + ` + + + + + `, + { fragment: true } +) + +// MDX processing options with all plugins +const mdxOptions = { + mdxOptions: { + remarkPlugins: [ + remarkExtractFrontmatter, + remarkGfm, + remarkCodeTitles, + remarkMath, + remarkImgToJsx, + ], + rehypePlugins: [ + rehypeSlug, + [ + rehypeAutolinkHeadings, + { + behavior: 'prepend', + headingProperties: { + className: ['content-header'], + }, + content: linkIcon, + }, + ], + [rehypePrismPlus, { defaultLanguage: 'tsx', ignoreMissing: true }], + ], + }, +} + +// Generate table of contents from MDX content +function generateTOC(content: string) { + const regXHeader = /\n(?#{1,3})\s+(?.+)/g + const slugger = new GithubSlugger() + + // Remove code blocks to avoid parsing headers inside code + const regXCodeBlock = /```[\s\S]*?```/g + const contentWithoutCodeBlocks = content.replace(regXCodeBlock, '') + + const headings = Array.from(contentWithoutCodeBlocks.matchAll(regXHeader)) + .map(({ groups }) => { + const flag = groups?.flag + const content = groups?.content + if (!content) return null + return { + value: content, + url: `#${slugger.slug(content)}`, + depth: flag?.length == 1 ? 1 : flag?.length == 2 ? 2 : 3, + } + }) + .filter((heading): heading is NonNullable => heading !== null) + + return headings +} export async function generateMetadata({ params, }: { params: { slug: string[] } -}): Promise { - const slug = decodeURI(params.slug.join('/')) - const post = allCaseStudies.find((p) => p.slug === slug) - - return { - title: post?.title, - description: post?.title, - openGraph: { - title: post?.title, - description: post?.title, - siteName: siteMetadata.title, - locale: 'en_US', - type: 'article', - url: './', - }, - twitter: { - card: 'summary_large_image', - title: post?.title, - description: post?.title, - }, +}): Promise { + try { + // Handle root case + if (!params.slug || params.slug.length === 0) { + return { + title: 'Case Studies - SigNoz', + description: 'Customer case studies and success stories with SigNoz', + openGraph: { + title: 'Case Studies - SigNoz', + description: 'Customer case studies and success stories with SigNoz', + type: 'website', + }, + } + } + + // Convert slug array to path + const path = params.slug.join('/') + + try { + const response = await fetchMDXContentByPath('case-studies', path, 'live') + const content = Array.isArray(response.data) ? response.data[0] : response.data + + return { + title: content?.title, + description: content?.description || content?.title, + openGraph: { + title: content?.title, + description: content?.description || content?.title, + siteName: siteMetadata.title, + locale: 'en_US', + type: 'article', + url: './', + }, + twitter: { + card: 'summary_large_image', + title: content?.title, + description: content?.description || content?.title, + }, + } + } catch (error) { + // Content not found, return 404 metadata + return { + title: 'Page Not Found', + description: 'The requested case study could not be found.', + robots: { + index: false, + follow: false, + }, + } + } + } catch (error) { + console.error('Error generating metadata:', error) + return { + title: 'Error', + description: 'An error occurred while loading the case study.', + } } } -export const generateStaticParams = async () => { - const paths = allCaseStudies.map((p) => ({ slug: p.slug?.split('/') })) - - return paths +// Generate static params - returning empty array to generate all pages at runtime +export async function generateStaticParams() { + return [] } export default async function Page({ params }: { params: { slug: string[] } }) { - const slug = decodeURI(params.slug.join('/')) - const post = allCaseStudies.find((p) => p.slug === slug) as CaseStudy + const path = params.slug.join('/') + console.log(`Fetching case study content for path: ${path}`) - if (!post) { + // Fetch content from Strapi with error handling + let content: MDXContent + try { + if (!process.env.NEXT_PUBLIC_SIGNOZ_CMS_API_URL) { + throw new Error('Strapi API URL is not configured') + } + + const response = await fetchMDXContentByPath('case-studies', path, 'live') + if (!response || !response.data) { + console.error(`Invalid response for path: ${path}`) + notFound() + } + content = Array.isArray(response.data) ? response.data[0] : response.data + } catch (error) { + console.error('Error fetching case study content:', error) notFound() } - const mainContent = coreContent(post) - const Layout = CaseStudyLayout + if (!content) { + console.log(`No content returned for path: ${path}`) + notFound() + } + + // Generate computed fields + const readingTimeData = readingTime(content?.content) + const toc = generateTOC(content?.content) + + // Compile MDX content with all plugins + let compiledContent + try { + const { content: mdxContent } = await compileMDX({ + source: content?.content, + components, + options: mdxOptions as any, + }) + compiledContent = mdxContent + } catch (error) { + console.error('Error compiling MDX:', error) + notFound() + } + + // Prepare content for CaseStudyLayout + const mainContent: CoreContent = { + title: content?.title, + slug: path, + path: content?.path || `/case-study/${path}`, + type: 'CaseStudy', + readingTime: readingTimeData, + filePath: `/case-study/${path}`, + toc: toc, + image: content.image, + authors: content.authors?.map((author) => author?.key) || [], + } return ( <> - - - + +
{compiledContent}
+
) } diff --git a/app/case-study/loading.tsx b/app/case-study/loading.tsx deleted file mode 100644 index 869139f44..000000000 --- a/app/case-study/loading.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "@/components/GlobalLoader/GlobalLoader" \ No newline at end of file diff --git a/app/comparisons/[...slug]/page.tsx b/app/comparisons/[...slug]/page.tsx index 09a362987..7da8c6afb 100644 --- a/app/comparisons/[...slug]/page.tsx +++ b/app/comparisons/[...slug]/page.tsx @@ -1,11 +1,11 @@ import 'css/prism.css' +import 'css/tailwind.css' +import 'css/post.css' +import 'css/global.css' +import 'css/doc.css' import 'katex/dist/katex.css' import { components } from '@/components/MDXComponents' -import { MDXLayoutRenderer } from 'pliny/mdx-components' -import { sortPosts, coreContent, allCoreContent } from 'pliny/utils/contentlayer' -import { allAuthors, allComparisons } from 'contentlayer/generated' -import type { Authors, Comparison } from 'contentlayer/generated' import OpenTelemetryLayout from '@/layouts/OpenTelemetryLayout' import BlogLayout from '@/layouts/BlogLayout' import { Metadata } from 'next' @@ -13,6 +13,26 @@ import siteMetadata from '@/data/siteMetadata' import { notFound } from 'next/navigation' import PageFeedback from '../../../components/PageFeedback/PageFeedback' import React from 'react' +import { fetchMDXContentByPath, MDXContent } from '@/utils/strapi' +import { generateStructuredData } from '@/utils/structuredData' +import { compileMDX } from 'next-mdx-remote/rsc' +import readingTime from 'reading-time' +import GithubSlugger from 'github-slugger' +import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic' +import { CoreContent } from 'pliny/utils/contentlayer' +// import type { Authors, Comparison } from 'contentlayer/generated' + +// Remark and rehype plugins +import remarkGfm from 'remark-gfm' +import { + remarkExtractFrontmatter, + remarkCodeTitles, + remarkImgToJsx, +} from 'pliny/mdx-plugins/index.js' +import rehypeSlug from 'rehype-slug' +import rehypeAutolinkHeadings from 'rehype-autolink-headings' +import rehypePrismPlus from 'rehype-prism-plus' +import remarkMath from 'remark-math' const defaultLayout = 'BlogLayout' const layouts = { @@ -20,92 +40,263 @@ const layouts = { BlogLayout, } -export const dynamicParams = false -export const dynamic = 'force-static' +export const revalidate = 0 +export const dynamicParams = true + +// Heroicon mini link for auto-linking headers +const linkIcon = fromHtmlIsomorphic( + ` + + + + + `, + { fragment: true } +) + +// MDX processing options with all plugins +const mdxOptions = { + mdxOptions: { + remarkPlugins: [ + remarkExtractFrontmatter, + remarkGfm, + remarkCodeTitles, + remarkMath, + remarkImgToJsx, + ], + rehypePlugins: [ + rehypeSlug, + [ + rehypeAutolinkHeadings, + { + behavior: 'prepend', + headingProperties: { + className: ['content-header'], + }, + content: linkIcon, + }, + ], + [rehypePrismPlus, { defaultLanguage: 'tsx', ignoreMissing: true }], + ], + }, +} + +// Generate table of contents from MDX content +function generateTOC(content: string) { + const regXHeader = /\n(?#{1,3})\s+(?.+)/g + const slugger = new GithubSlugger() + + // Remove code blocks to avoid parsing headers inside code + const regXCodeBlock = /```[\s\S]*?```/g + const contentWithoutCodeBlocks = content.replace(regXCodeBlock, '') + + const headings = Array.from(contentWithoutCodeBlocks.matchAll(regXHeader)) + .map(({ groups }) => { + const flag = groups?.flag + const content = groups?.content + if (!content) return null + return { + value: content, + url: `#${slugger.slug(content)}`, + depth: flag?.length == 1 ? 1 : flag?.length == 2 ? 2 : 3, + } + }) + .filter((heading): heading is NonNullable => heading !== null) + + return headings +} export async function generateMetadata({ params, }: { params: { slug: string[] } -}): Promise { - const slug = decodeURI(params.slug.join('/')) - const post = allComparisons.find((p) => p.slug === slug) +}): Promise { + try { + // Convert slug array to path + const path = params.slug.join('/') + try { + const isProduction = process.env.VERCEL_ENV === 'production' + const deployment_status = isProduction ? 'live' : 'staging' + const response = await fetchMDXContentByPath('comparisons', path, deployment_status) + const content = response.data as MDXContent - if (!post) { - return notFound() - } + // Extract author names from the content + const authorNames = content.authors?.map((author) => author?.name) || ['SigNoz Team'] - const authorList = post?.authors || ['default'] - const authorDetails = authorList.map((author) => { - const authorResults = allAuthors.find((p) => p.slug === author) - return coreContent(authorResults as Authors) - }) - - const publishedAt = new Date(post.date).toISOString() - const modifiedAt = new Date(post.lastmod || post.date).toISOString() - const authors = authorDetails.map((author) => author.name) - let imageList = [siteMetadata.socialBanner] - if (post.image) { - imageList = typeof post.image === 'string' ? [post.image] : post.image - } - const ogImages = imageList.map((img) => { + const publishedAt = new Date(content.date).toISOString() + const modifiedAt = new Date(content.lastmod || content.date).toISOString() + + let imageList = [siteMetadata.socialBanner] + if (content.image) { + imageList = typeof content.image === 'string' ? [content.image] : content.image + } + const ogImages = imageList.map((img) => { + return { + url: img.includes('http') ? img : siteMetadata.siteUrl + img, + } + }) + + return { + title: content.title, + description: content?.description, + openGraph: { + title: content.title, + description: content?.description, + siteName: siteMetadata.title, + locale: 'en_US', + type: 'article', + publishedTime: publishedAt, + modifiedTime: modifiedAt, + url: './', + images: ogImages, + authors: authorNames.length > 0 ? authorNames : [siteMetadata.author], + }, + twitter: { + card: 'summary_large_image', + title: content.title, + description: content?.description, + images: imageList, + }, + } + } catch (error) { + // Content not found, return 404 metadata + return { + title: 'Page Not Found', + description: 'The requested comparison could not be found.', + robots: { + index: false, + follow: false, + }, + } + } + } catch (error) { + console.error('Error generating metadata:', error) return { - url: img.includes('http') ? img : siteMetadata.siteUrl + img, + title: 'Error', + description: 'An error occurred while loading the comparison.', } - }) - - return { - title: post.title, - description: post?.description, - openGraph: { - title: post.title, - description: post?.description, - siteName: siteMetadata.title, - locale: 'en_US', - type: 'article', - publishedTime: publishedAt, - modifiedTime: modifiedAt, - url: './', - images: ogImages, - authors: authors.length > 0 ? authors : [siteMetadata.author], - }, - twitter: { - card: 'summary_large_image', - title: post.title, - description: post?.description, - images: imageList, - }, } } -export const generateStaticParams = async () => { - const paths = allComparisons.map((p) => ({ slug: p.slug?.split('/') })) - - return paths +// Generate static params - returning empty array to generate all pages at runtime +export async function generateStaticParams() { + return [] } export default async function Page({ params }: { params: { slug: string[] } }) { - const slug = decodeURI(params.slug.join('/')) - // Filter out drafts in production - const sortedCoreContents = allCoreContent(sortPosts(allComparisons)) - const postIndex = sortedCoreContents.findIndex((p) => p.slug === slug) - if (postIndex === -1) { - return notFound() + if (!params.slug || params.slug.length === 0) { + return
Redirecting to comparisons index...
} - const post = allComparisons.find((p) => p.slug === slug) as Comparison - const authorList = post?.authors || ['default'] - const authorDetails = authorList.map((author) => { - const authorResults = allAuthors.find((p) => p.slug === author) - return coreContent(authorResults as Authors) - }) - const mainContent = coreContent(post) - const jsonLd = post.structuredData + const path = params.slug.join('/') + console.log(`Fetching comparison content for path: ${path}`) + + // Fetch content from Strapi with error handling + let content: MDXContent + try { + if (!process.env.NEXT_PUBLIC_SIGNOZ_CMS_API_URL) { + throw new Error('Strapi API URL is not configured') + } + + const isProduction = process.env.VERCEL_ENV === 'production' + const deployment_status = isProduction ? 'live' : 'staging' + + const response = await fetchMDXContentByPath('comparisons', path, deployment_status) + if (!response || !response.data) { + console.error(`Invalid response for path: ${path}`) + notFound() + } + content = response.data as MDXContent + } catch (error) { + console.error('Error fetching comparison content:', error) + notFound() + } + + if (!content) { + console.log(`No content returned for path: ${path}`) + notFound() + } + + // Generate computed fields + const readingTimeData = readingTime(content?.content) + const toc = generateTOC(content?.content) + + // Compile MDX content with all plugins + let compiledContent + try { + const { content: mdxContent } = await compileMDX({ + source: content?.content, + components, + options: mdxOptions as any, + }) + compiledContent = mdxContent + } catch (error) { + console.error('Error compiling MDX:', error) + notFound() + } + + // Generate structured data + const structuredData = generateStructuredData('comparisons', content) + + // Prepare content for Layout + const mainContent: CoreContent = { + title: content.title, + date: content.date, + lastmod: content.lastmod, + tags: content.tags?.map((tag) => tag.value) || [], + draft: content.deployment_status === 'draft', + summary: content.summary, + description: content.description, + images: content.images || [], + authors: content.authors?.map((author) => author?.key) || [], + slug: path, + path: content.path || `/comparisons/${path}`, + type: 'Comparison', + readingTime: readingTimeData, + filePath: `/comparisons/${path}`, + structuredData: structuredData, + toc: toc, + relatedArticles: [], + } + + // Prepare author details + const authorDetails: CoreContent[] = content.authors?.map((author) => ({ + name: author?.name || 'Unknown Author', + avatar: author?.image_url || '/static/images/signoz-logo.png', + occupation: author?.title || 'Developer Tools', + company: 'SigNoz', + email: 'team@signoz.io', + twitter: 'https://twitter.com/SigNozHQ', + linkedin: 'https://www.linkedin.com/company/signoz', + github: 'https://github.com/SigNoz/signoz', + path: `/authors/${author?.key || 'default'}`, + type: 'Authors', + slug: author?.key || 'default', + readingTime: { text: '', minutes: 0, time: 0, words: 0 }, + filePath: `/data/authors/${author?.key || 'default'}.mdx`, + })) || [ + { + // Fallback author if no authors are found + name: 'SigNoz Team', + avatar: '/static/images/signoz-logo.png', + occupation: 'Developer Tools', + company: 'SigNoz', + email: 'team@signoz.io', + twitter: 'https://twitter.com/SigNozHQ', + linkedin: 'https://www.linkedin.com/company/signoz', + github: 'https://github.com/SigNoz/signoz', + path: '/authors/default', + type: 'Authors', + slug: 'default', + readingTime: { text: '', minutes: 0, time: 0, words: 0 }, + filePath: '/data/authors/default.mdx', + }, + ] // Choose layout based on slug or post layout - let layoutName = post.layout || defaultLayout - if (slug.includes('opentelemetry')) { + let layoutName = content.layout || defaultLayout + if (path.includes('opentelemetry')) { layoutName = 'OpenTelemetryLayout' } else { layoutName = 'BlogLayout' @@ -118,15 +309,17 @@ export default async function Page({ params }: { params: { slug: string[] } }) { <>