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[] } }) { <> author?.key) || []} + toc={toc} > - + + {compiledContent} + > diff --git a/app/comparisons/loading.tsx b/app/comparisons/loading.tsx deleted file mode 100644 index 869139f44..000000000 --- a/app/comparisons/loading.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "@/components/GlobalLoader/GlobalLoader" \ No newline at end of file diff --git a/app/faqs/FAQsClient.tsx b/app/faqs/FAQsClient.tsx new file mode 100644 index 000000000..ecfaa3d62 --- /dev/null +++ b/app/faqs/FAQsClient.tsx @@ -0,0 +1,115 @@ +'use client' + +import Button from '@/components/ui/Button' +import Link from 'next/link' +import React, { useState } from 'react' + +interface FAQ { + title: string + description: string + path: string + date: string + tags: string[] + draft: boolean +} + +interface FAQsClientProps { + faqs: FAQ[] +} + +export default function FAQsClient({ faqs }: FAQsClientProps) { + const [selectedTags, setSelectedTags] = useState([]) + + // Get unique tags from all FAQs + const allTags = Array.from( + new Set(faqs.filter((faq) => !faq.draft).flatMap((faq) => faq.tags || [])) + ).sort() + + // Filter only by tags + const filteredFaqs = faqs + .filter((faq) => !faq.draft) + .filter( + (faq) => selectedTags.length === 0 || selectedTags.some((tag) => faq.tags?.includes(tag)) + ) + + const toggleTag = (tag: string) => { + setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag])) + } + + console.log('filteredFaqs', filteredFaqs) + + return ( + + + + + + + + Frequently Asked Questions + + + Find answers to common questions about SigNoz's features, capabilities, and + implementation + + + + {allTags.map((tag) => ( + toggleTag(tag)} + className={`rounded-full px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm ${ + selectedTags.includes(tag) + ? 'bg-primary-500 text-signoz_vanilla-100' + : 'bg-signoz_ink-400 text-signoz_vanilla-400 hover:bg-signoz_ink-300' + } border border-signoz_slate-400`} + > + {tag} + + ))} + + {selectedTags.length > 0 && ( + setSelectedTags([])} + variant="link" + className="mt-4 w-full justify-center text-xs font-medium text-primary-400 hover:text-primary-300 sm:text-sm" + > + Clear filters + + )} + + + + + + {filteredFaqs.map((faq) => ( + + + + + + {faq.title} + + + {faq.description} + + + + Read more → + + + + + ))} + + + + + ) +} diff --git a/app/faqs/[...slug]/page.tsx b/app/faqs/[...slug]/page.tsx index ed3c19571..df1c85137 100644 --- a/app/faqs/[...slug]/page.tsx +++ b/app/faqs/[...slug]/page.tsx @@ -1,9 +1,9 @@ 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 { allFAQs, allAuthors } from 'contentlayer/generated' -import type { Authors } from 'contentlayer/generated' import FAQLayout from '@/layouts/FAQLayout' import { Metadata } from 'next' import siteMetadata from '@/data/siteMetadata' @@ -11,84 +11,290 @@ import { notFound } from 'next/navigation' import Link from 'next/link' import { SidebarIcons } from '@/components/sidebar-icons/icons' import Button from '@/components/ui/Button' +import { fetchMDXContentByPath, MDXContent } from '@/utils/strapi' +import { generateStructuredData } from '@/utils/structuredData' +import { CoreContent } from 'pliny/utils/contentlayer' +import { Blog, Authors } from 'contentlayer/generated' +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' + +// 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 = allFAQs.find((p) => p.slug === slug) - const authorList = post?.authors || ['default'] - const authorDetails = authorList.map((author) => { - const authorResults = allAuthors.find((p) => p.slug === author) - return coreContent(authorResults as Authors) - }) - if (!post) { - return - } +}): Promise { + try { + // Convert slug array to path + const path = params.slug.join('/') - const publishedAt = new Date(post.date).toISOString() - const modifiedAt = new Date(post.lastmod || post.date).toISOString() - const authors = authorDetails.map((author) => author.name) - - 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: './', - authors: authors.length > 0 ? authors : [siteMetadata.author], - }, - twitter: { - card: 'summary_large_image', - title: post.title, - description: post.description, - }, + const isProduction = process.env.VERCEL_ENV === 'production' + + try { + const deployment_status = isProduction ? 'live' : 'staging' + const { data: content } = await fetchMDXContentByPath('faqs', path, deployment_status) + + // Extract author names from the content + const authorNames = (content as MDXContent)?.authors?.map((author) => author?.name) || [ + 'SigNoz Team', + ] + + return { + title: (content as MDXContent).title, + description: + (content as MDXContent)?.description || `${(content as MDXContent)?.title} - SigNoz FAQ`, + authors: authorNames.map((name) => ({ name })), + openGraph: { + title: (content as MDXContent)?.title, + description: + (content as MDXContent)?.description || + `${(content as MDXContent)?.title} - SigNoz FAQ`, + siteName: siteMetadata.title, + locale: 'en_US', + type: 'article', + publishedTime: (content as MDXContent)?.date, + modifiedTime: (content as MDXContent)?.updatedAt, + url: (content as MDXContent)?.path || './', + authors: authorNames, + }, + twitter: { + card: 'summary_large_image', + title: (content as MDXContent)?.title, + description: + (content as MDXContent)?.description || + `${(content as MDXContent)?.title} - SigNoz FAQ`, + }, + } + } catch (error) { + // Content not found, return 404 metadata + return { + title: 'Page Not Found', + description: 'The requested FAQ page 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 FAQ page.', + } } } -export const generateStaticParams = async () => { - const paths = allFAQs.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 sortedCoreContents = allCoreContent(sortPosts(allFAQs)) - const postIndex = sortedCoreContents.findIndex((p) => p.slug === slug) - if (postIndex === -1) { - return notFound() + if (!params.slug || params.slug.length === 0) { + return Redirecting to FAQs index... + } + + const path = params.slug.join('/') + console.log(`Fetching FAQ content for path: ${path}`) + + const isProduction = process.env.VERCEL_ENV === 'production' + + // 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 deployment_status = isProduction ? 'live' : 'staging' + + const response = await fetchMDXContentByPath('faqs', 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 FAQ content:', error) + notFound() } - const post = allFAQs.find((p) => p.slug === slug) - if (!post) { - return notFound() + if (!content) { + console.log(`No content returned for path: ${path}`) + notFound() } - 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 as any) - const jsonLd = post.structuredData + // 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() + } + + console.log('logging content tags', content.tags) + + // Generate structured data + const structuredData = generateStructuredData('faqs', content) + + // Prepare content for FAQLayout + const mainContent: CoreContent = { + title: content.title, + date: content.date, + lastmod: content.updatedAt, + tags: content.tags?.data?.map((tag) => tag.attributes?.name) || [], + draft: content.deployment_status === 'draft', + summary: content.description, + images: content.images || [], + authors: content.authors?.map((author) => author?.name) || [], + slug: path, + path: content.path || `/faqs/${path}`, + type: 'Blog', + readingTime: readingTimeData, + filePath: `/faqs/${path}`, + structuredData: structuredData, + toc: toc, + relatedArticles: + content.related_faqs?.data?.map((faq) => ({ + title: faq.attributes?.title, + slug: faq.attributes?.path, + date: faq.attributes?.date, + })) || [], + } + + // Prepare author details from the authors relation + const authorDetails: CoreContent[] = content.authors?.data?.map((author) => ({ + name: author.attributes?.name || 'Unknown Author', + avatar: author.attributes?.image_url || '/static/images/signoz-logo.png', + occupation: author.attributes?.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.attributes?.key || 'default'}`, + type: 'Authors', + slug: author.attributes?.key || 'default', + readingTime: { text: '', minutes: 0, time: 0, words: 0 }, + filePath: `/data/authors/${author.attributes?.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', + }, + ] return ( <> - + Back to FAQs @@ -96,17 +302,23 @@ export default async function Page({ params }: { params: { slug: string[] } }) { - author?.name) || []} + toc={toc} + tags={content.tags?.map((tag) => tag?.value) || []} + relatedArticles={ + content.related_faqs?.map((faq) => ({ + title: faq?.title, + publishedOn: faq?.date, + url: `/faqs/${faq?.path}`, + })) || [] + } > - + + {compiledContent} + > ) diff --git a/app/faqs/loading.tsx b/app/faqs/loading.tsx deleted file mode 100644 index 869139f44..000000000 --- a/app/faqs/loading.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "@/components/GlobalLoader/GlobalLoader" \ No newline at end of file diff --git a/app/faqs/page.tsx b/app/faqs/page.tsx index 93c73151d..6f08a5bd3 100644 --- a/app/faqs/page.tsx +++ b/app/faqs/page.tsx @@ -1,109 +1,44 @@ -'use client' - -import Button from '@/components/ui/Button' -import { allFAQs } from 'contentlayer/generated' -import Link from 'next/link' -import React, { useState } from 'react' - -export default function FAQsPage() { - const [selectedTags, setSelectedTags] = useState([]) - - // Get unique tags from all FAQs - const allTags = Array.from( - new Set( - allFAQs - .filter((faq) => !faq.draft) - .flatMap((faq) => faq.tags || []) - ) - ).sort() - - // Filter only by tags - const faqs = allFAQs - .filter((faq) => !faq.draft) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) - .filter((faq) => - selectedTags.length === 0 || selectedTags.some(tag => faq.tags?.includes(tag)) - ) - - const toggleTag = (tag: string) => { - setSelectedTags(prev => - prev.includes(tag) - ? prev.filter(t => t !== tag) - : [...prev, tag] - ) +import { fetchMDXContentByPath, MDXContentApiResponse } from '@/utils/strapi' +import { notFound } from 'next/navigation' +import FAQsClient from './FAQsClient' + +export const revalidate = 0 +export const dynamicParams = true + +export default async function FAQsPage() { + const isProduction = process.env.VERCEL_ENV === 'production' + const deploymentStatus = isProduction ? 'live' : 'staging' + + try { + // Fetch all FAQs from Strapi + const response = (await fetchMDXContentByPath( + 'faqs', + undefined, + deploymentStatus, + true + )) as MDXContentApiResponse + + if (!response || !response.data) { + console.error('Invalid response from Strapi') + notFound() + } + + // Transform the data to match the client component's expected format + const faqs = response.data.map((faq) => ({ + title: faq.title, + description: faq.description, + path: faq.path, + date: faq.date, + tags: faq.tags?.map((tag) => tag.value) || [], + draft: faq.deployment_status === 'draft', + })) + + // Sort by date (since API sorts but we want to ensure client side too) + const sortedFaqs = faqs.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + + return + } catch (error) { + console.error('Error fetching FAQs:', error) + notFound() } - - return ( - - - - - - - - Frequently Asked Questions - - - Find answers to common questions about SigNoz's features, capabilities, and implementation - - - - {allTags.map((tag) => ( - toggleTag(tag as string)} - className={`rounded-full px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium transition-colors ${ - selectedTags.includes(tag as string) - ? 'bg-primary-500 text-signoz_vanilla-100' - : 'bg-signoz_ink-400 text-signoz_vanilla-400 hover:bg-signoz_ink-300' - } border border-signoz_slate-400`} - > - {tag} - - ))} - - {selectedTags.length > 0 && ( - setSelectedTags([])} - variant="link" - className="mt-4 w-full justify-center text-xs sm:text-sm font-medium text-primary-400 hover:text-primary-300" - > - Clear filters - - )} - - - - - - {faqs.map((faq) => ( - - - - - - {faq.title} - - - {faq.description} - - - - Read more → - - - - - ))} - - - - - ) } diff --git a/app/guides/[...slug]/page.tsx b/app/guides/[...slug]/page.tsx index 035aa0e7d..c5e46a2a3 100644 --- a/app/guides/[...slug]/page.tsx +++ b/app/guides/[...slug]/page.tsx @@ -1,9 +1,9 @@ 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 { allGuides, allAuthors } from 'contentlayer/generated' -import type { Authors, Guide } from 'contentlayer/generated' import OpenTelemetryLayout from '@/layouts/OpenTelemetryLayout' import GuidesLayout from '@/layouts/GuidesLayout' import { Metadata } from 'next' @@ -14,6 +14,25 @@ import PageFeedback from '../../../components/PageFeedback/PageFeedback' import React from 'react' import GrafanaVsSigNozFloatingCard from '@/components/GrafanaVsSigNoz/GrafanaVsSigNozFloatingCard' import Button from '@/components/ui/Button' +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' + +// 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 = 'GuidesLayout' const layouts = { @@ -21,91 +40,263 @@ const layouts = { GuidesLayout, } -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 = allGuides.find((p) => p.slug === slug) +}): Promise { + try { + // Convert slug array to path + const path = params.slug.join('/') - if (!post) { - return notFound() - } + try { + const isProduction = process.env.VERCEL_ENV === 'production' + const deployment_status = isProduction ? 'live' : 'staging' + const response = await fetchMDXContentByPath('guides', path, deployment_status) + const content = response.data as MDXContent - 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) => { + // Extract author names from the content + const authorNames = content.authors?.map((author) => author?.name) || ['SigNoz Team'] + + 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.summary, + images: imageList, + }, + } + } catch (error) { + // Content not found, return 404 metadata + return { + title: 'Page Not Found', + description: 'The requested guide 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 guide.', } - }) - - 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.summary, - images: imageList, - }, } } -export const generateStaticParams = async () => { - const paths = allGuides.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(allGuides)) - const postIndex = sortedCoreContents.findIndex((p) => p.slug === slug) - if (postIndex === -1) { - return notFound() + if (!params.slug || params.slug.length === 0) { + return Redirecting to guides index... } - const post = allGuides.find((p) => p.slug === slug) as Guide - 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 guide 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('guides', 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 guide 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('guides', 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 || `/guides/${path}`, + type: 'Guide', + readingTime: readingTimeData, + filePath: `/guides/${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 = 'GuidesLayout' @@ -114,19 +305,23 @@ export default async function Page({ params }: { params: { slug: string[] } }) { // @ts-ignore const Layout = layouts[layoutName] - // Check if the slug contains Grafana or Prometheus + // Check if the path contains Grafana or Prometheus const isGrafanaOrPrometheusArticle = - slug.toLowerCase().includes('grafana') || slug.toLowerCase().includes('prometheus') + path.toLowerCase().includes('grafana') || path.toLowerCase().includes('prometheus') return ( <> - + Back to Guides @@ -137,14 +332,16 @@ export default async function Page({ params }: { params: { slug: string[] } }) { author?.key) || []} + toc={toc} > - + + {compiledContent} + - {/* Render GrafanaVsSigNozFloatingCard if the slug contains Grafana or Prometheus */} + {/* Render GrafanaVsSigNozFloatingCard if the path contains Grafana or Prometheus */} {isGrafanaOrPrometheusArticle && } > ) diff --git a/app/guides/page/[page]/page.tsx b/app/guides/page/[page]/page.tsx index 54648306f..8696b5403 100644 --- a/app/guides/page/[page]/page.tsx +++ b/app/guides/page/[page]/page.tsx @@ -1,34 +1,87 @@ import ListLayout from '@/layouts/ListLayoutWithTags' -import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer' -import { allGuides } from 'contentlayer/generated' +import { fetchMDXContentByPath, MDXContentApiResponse } from '@/utils/strapi' +import readingTime from 'reading-time' +import { notFound } from 'next/navigation' +import { CoreContent } from 'pliny/utils/contentlayer' const POSTS_PER_PAGE = 5 -export const generateStaticParams = async () => { - const totalPages = Math.ceil(allGuides.length / POSTS_PER_PAGE) - const paths = Array.from({ length: totalPages }, (_, i) => ({ page: (i + 1).toString() })) +export const revalidate = 0 +export const dynamicParams = true - return paths +export const generateStaticParams = async () => { + // Return empty array for ISR + return [] } -export default function Page({ params }: { params: { page: string } }) { - const posts = allCoreContent(sortPosts(allGuides)) - const pageNumber = parseInt(params.page as string) - const initialDisplayPosts = posts.slice( - POSTS_PER_PAGE * (pageNumber - 1), - POSTS_PER_PAGE * pageNumber - ) - const pagination = { - currentPage: pageNumber, - totalPages: Math.ceil(posts.length / POSTS_PER_PAGE), - } +export default async function Page({ params }: { params: { page: string } }) { + try { + // Fetch all guides from Strapi + const isProduction = process.env.VERCEL_ENV === 'production' + const deployment_status = isProduction ? 'live' : 'staging' + + const response = (await fetchMDXContentByPath( + 'guides', + undefined, + deployment_status, + true + )) as MDXContentApiResponse + + if (!response || !response.data) { + console.error('Invalid response from Strapi for guides') + notFound() + } + + // Transform the data to match the expected format + const guides = response.data.map((guide) => { + const readingTimeData = readingTime(guide.content || '') - return ( - - ) + return { + title: guide.title, + description: guide.description, + summary: guide.summary, + date: guide.date, + lastmod: guide.lastmod, + tags: guide.tags?.map((tag) => tag.value) || [], + draft: guide.deployment_status === 'draft', + slug: guide.path?.replace(/^\//, '') || '', + path: guide.path, + image: guide.image, + images: guide.images || [], + authors: guide.authors?.map((author) => author?.key) || [], + readingTime: readingTimeData, + type: 'Guide', + } + }) + + // Sort by date (descending) + const sortedGuides = guides.sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() + ) + + // Filter out drafts + const posts = sortedGuides.filter((g) => !g.draft) + + const pageNumber = parseInt(params.page as string) + const initialDisplayPosts = posts.slice( + POSTS_PER_PAGE * (pageNumber - 1), + POSTS_PER_PAGE * pageNumber + ) + const pagination = { + currentPage: pageNumber, + totalPages: Math.ceil(posts.length / POSTS_PER_PAGE), + } + + return ( + []} // TODO: fix types here, adding any for build + initialDisplayPosts={initialDisplayPosts as CoreContent[]} + pagination={pagination} + title="All Posts" + /> + ) + } catch (error) { + console.error('Error fetching guides:', error) + notFound() + } } diff --git a/app/layout.tsx b/app/layout.tsx index c44ee335d..068ed2d55 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -107,7 +107,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - + {/* */} @@ -117,7 +117,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - + {/* */}
+ Find answers to common questions about SigNoz's features, capabilities, and + implementation +
+ {faq.description} +
- Find answers to common questions about SigNoz's features, capabilities, and implementation -
- {faq.description} -