diff --git a/app/faqs/FAQsClient.tsx b/app/faqs/FAQsClient.tsx new file mode 100644 index 0000000000..8a07e6ccd6 --- /dev/null +++ b/app/faqs/FAQsClient.tsx @@ -0,0 +1,113 @@ +'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])) + } + + return ( +
+
+
+ +
+
+

+ Frequently Asked Questions +

+

+ Find answers to common questions about SigNoz's features, capabilities, and + implementation +

+
+
+ {allTags.map((tag) => ( + + ))} +
+ {selectedTags.length > 0 && ( + + )} +
+
+ +
+
    + {filteredFaqs.map((faq) => ( +
  • + +
    +
    +

    + {faq.title} +

    +

    + {faq.description} +

    +
    +
    + Read more → +
    +
    + +
  • + ))} +
+
+
+
+ ) +} diff --git a/app/faqs/[...slug]/page.tsx b/app/faqs/[...slug]/page.tsx index ed3c195715..72e90a1f13 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,287 @@ 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('/') + + 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() + } + + // 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 ( <>