public: feature pages

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-07 16:42:02 +00:00
parent ed8b5c667e
commit 6ce9b5dd1b
127 changed files with 3140 additions and 81 deletions

View File

@@ -34,7 +34,13 @@ export function CompareHero({ hero, tocItems = [] }: CompareHeroProps) {
<div className="row gap-4">
<GetStartedButton />
<Button size="lg" variant="outline" asChild>
<Link href={'https://demo.openpanel.dev'}>See live demo</Link>
<Link
href={'https://demo.openpanel.dev'}
target="_blank"
rel="noreferrer noopener nofollow"
>
See live demo
</Link>
</Button>
</div>
<Perks

View File

@@ -0,0 +1,33 @@
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import type { FeatureCapability } from '@/lib/features';
import { ZapIcon } from 'lucide-react';
interface CapabilitiesProps {
title: string;
intro?: string;
capabilities: FeatureCapability[];
}
export function Capabilities({ title, intro, capabilities }: CapabilitiesProps) {
return (
<Section className="container">
<SectionHeader
title={title}
description={intro}
variant="sm"
className="mb-12"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{capabilities.map((cap) => (
<FeatureCard
key={cap.title}
title={cap.title}
description={cap.description}
icon={ZapIcon}
/>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,49 @@
import { FaqItem, Faqs } from '@/components/faq';
import { Section, SectionHeader } from '@/components/section';
import type { FeatureFaqs } from '@/lib/features';
import Script from 'next/script';
interface FeatureFaqProps {
faqs: FeatureFaqs;
}
export function FeatureFaq({ faqs }: FeatureFaqProps) {
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.items.map((q) => ({
'@type': 'Question',
name: q.question,
acceptedAnswer: {
'@type': 'Answer',
text: q.answer,
},
})),
};
return (
<Section className="container">
<Script
strategy="beforeInteractive"
id="feature-faq-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SectionHeader
className="mb-16"
title={faqs.title}
description={faqs.intro}
variant="sm"
/>
<Faqs>
{faqs.items.map((faq) => (
<FaqItem key={faq.question} question={faq.question}>
{faq.answer}
</FaqItem>
))}
</Faqs>
</div>
</Section>
);
}

View File

@@ -0,0 +1,47 @@
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { GetStartedButton } from '@/components/get-started-button';
import { Perks } from '@/components/perks';
import { SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import type { FeatureHero as FeatureHeroData } from '@/lib/features';
import { CheckCircle2Icon } from 'lucide-react';
import Link from 'next/link';
interface FeatureHeroProps {
hero: FeatureHeroData;
}
export function FeatureHero({ hero }: FeatureHeroProps) {
return (
<HeroContainer divider={false} className="-mb-32">
<div className="col gap-6">
<SectionHeader
as="h1"
className="flex-1"
title={hero.heading}
description={hero.subheading}
variant="sm"
/>
<div className="row gap-4">
<GetStartedButton />
<Button size="lg" variant="outline" asChild>
<Link
href="https://demo.openpanel.dev"
target="_blank"
rel="noreferrer noopener nofollow"
>
See live demo
</Link>
</Button>
</div>
<Perks
className="flex gap-4 flex-wrap"
perks={hero.badges.map((badge) => ({
text: badge,
icon: CheckCircle2Icon,
}))}
/>
</div>
</HeroContainer>
);
}

View File

@@ -0,0 +1,30 @@
import { Section, SectionHeader } from '@/components/section';
import type { FeatureUseCases } from '@/lib/features';
interface FeatureUseCasesProps {
useCases: FeatureUseCases;
}
export function FeatureUseCasesSection({ useCases }: FeatureUseCasesProps) {
return (
<Section className="container">
<SectionHeader
title={useCases.title}
description={useCases.intro}
variant="sm"
className="mb-12"
/>
<div className="grid md:grid-cols-2 gap-6">
{useCases.items.map((useCase) => (
<div
key={useCase.title}
className="col gap-2 p-6 border rounded-2xl bg-card/50"
>
<h3 className="font-semibold">{useCase.title}</h3>
<p className="text-sm text-muted-foreground">{useCase.description}</p>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,43 @@
import { Section, SectionHeader } from '@/components/section';
import type { FeatureHowItWorks } from '@/lib/features';
import { cn } from '@/lib/utils';
interface HowItWorksProps {
data: FeatureHowItWorks;
}
export function HowItWorks({ data }: HowItWorksProps) {
return (
<Section className="container">
<SectionHeader
title={data.title}
description={data.intro}
variant="sm"
className="mb-12"
/>
<div className="relative">
{data.steps.map((step, index) => (
<div
key={step.title}
className="relative flex gap-4 mb-8 last:mb-0 min-w-0"
>
<div className="flex flex-col items-center shrink-0">
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
{index + 1}
</div>
{index < data.steps.length - 1 && (
<div className="w-0.5 bg-border mt-2 flex-1 min-h-[2rem]" />
)}
</div>
<div className="flex-1 pt-1 min-w-0 pb-8">
<h3 className="font-semibold text-foreground mb-1">{step.title}</h3>
<p className={cn('text-muted-foreground text-sm')}>
{step.description}
</p>
</div>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,49 @@
import { FeatureCardContainer } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import type { RelatedFeature } from '@/lib/features';
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
interface RelatedFeaturesProps {
title?: string;
related: RelatedFeature[];
}
export function RelatedFeatures({
title = 'Related features',
related,
}: RelatedFeaturesProps) {
if (related.length === 0) return null;
return (
<Section className="container">
<SectionHeader
title={title}
description="Explore more capabilities that work together with this feature."
variant="sm"
className="mb-12"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{related.map((item) => (
<Link key={item.slug} href={`/features/${item.slug}`}>
<FeatureCardContainer>
<div className="row gap-3 items-center">
<div className="col gap-1 flex-1 min-w-0">
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
{item.title}
</h3>
{item.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{item.description}
</p>
)}
</div>
<ArrowRightIcon className="opacity-0 group-hover:opacity-100 size-5 shrink-0 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all duration-300" />
</div>
</FeatureCardContainer>
</Link>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,22 @@
import { Section, SectionHeader } from '@/components/section';
import type { FeatureDefinition } from '@/lib/features';
import { cn } from '@/lib/utils';
import Markdown from 'react-markdown';
interface WhatItIsProps {
definition: FeatureDefinition;
className?: string;
}
export function WhatItIs({ definition, className }: WhatItIsProps) {
return (
<Section className={cn('container', className)}>
{definition.title && (
<SectionHeader title={definition.title} variant="sm" className="mb-8" />
)}
<div className="prose prose-lg max-w-3xl text-muted-foreground [&_strong]:text-foreground">
<Markdown>{definition.text}</Markdown>
</div>
</Section>
);
}

View File

@@ -0,0 +1,142 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { WindowImage } from '@/components/window-image';
import {
type FeatureData,
getAllFeatureSlugs,
getFeatureData,
} from '@/lib/features';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
import { Capabilities } from './_components/capabilities';
import { FeatureFaq } from './_components/feature-faq';
import { FeatureHero } from './_components/feature-hero';
import { FeatureUseCasesSection } from './_components/feature-use-cases';
import { HowItWorks } from './_components/how-it-works';
import { RelatedFeatures } from './_components/related-features';
import { WhatItIs } from './_components/what-it-is';
export async function generateStaticParams() {
const slugs = await getAllFeatureSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const data = await getFeatureData(slug);
if (!data) {
return {
title: 'Feature Not Found',
};
}
return getPageMetadata({
title: data.seo.title,
description: data.seo.description,
url: data.url,
image: getOgImageUrl(data.url),
});
}
export default async function FeaturePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const data = await getFeatureData(slug);
if (!data) {
return notFound();
}
const pageUrl = url(`/features/${slug}`);
const capabilitiesSection = data.capabilities_section ?? {
title: 'What you can do',
intro: undefined,
};
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: data.seo.title,
description: data.seo.description,
url: pageUrl,
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.webp'),
},
},
};
return (
<div>
<Script
id="feature-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<FeatureHero hero={data.hero} />
{data.screenshots[0] && (
<div className="container my-16">
<WindowImage {...data.screenshots[0]} />
</div>
)}
<WhatItIs definition={data.definition} />
<Capabilities
title={capabilitiesSection.title}
intro={capabilitiesSection.intro}
capabilities={data.capabilities}
/>
{data.screenshots[1] && (
<div className="container my-16">
<WindowImage {...data.screenshots[1]} />
</div>
)}
{data.how_it_works && (
<div id="how-it-works">
<HowItWorks data={data.how_it_works} />
</div>
)}
{data.screenshots[2] && (
<div className="container my-16">
<WindowImage {...data.screenshots[2]} />
</div>
)}
<div id="use-cases">
<FeatureUseCasesSection useCases={data.use_cases} />
</div>
<RelatedFeatures related={data.related_features} />
<div id="faq">
<FeatureFaq faqs={data.faqs} />
</div>
<CtaBanner
title="Ready to get started?"
description="Track events in minutes. Free 30-day trial, no credit card required."
ctaText={data.cta.label}
ctaLink={data.cta.href}
/>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { FeatureCardContainer } from '@/components/feature-card';
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
interface FeatureCardLinkProps {
url: string;
title: string;
description: string;
}
export function FeatureCardLink({
url,
title,
description,
}: FeatureCardLinkProps) {
return (
<Link href={url}>
<FeatureCardContainer>
<div className="row gap-3 items-center">
<div className="col gap-1 flex-1 min-w-0">
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
{title}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
</div>
<ArrowRightIcon className="opacity-0 group-hover:opacity-100 size-5 shrink-0 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all duration-300" />
</div>
</FeatureCardContainer>
</Link>
);
}

View File

@@ -0,0 +1,68 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { FeatureHero } from '@/app/(content)/features/[slug]/_components/feature-hero';
import { Section, SectionHeader } from '@/components/section';
import { WindowImage } from '@/components/window-image';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { featureSource } from '@/lib/source';
import type { Metadata } from 'next';
import { FeatureCardLink } from './_components/feature-card';
export const metadata: Metadata = getPageMetadata({
title: 'Product analytics features',
description:
'Explore OpenPanel features: event tracking, funnels, retention, user profiles, and more. Privacy-first product analytics that just works.',
url: url('/features'),
image: getOgImageUrl('/features'),
});
const heroData = {
heading: 'Product analytics features',
subheading:
'Everything you need to understand user behavior, conversion, and retention. Simple event-based analytics without the complexity.',
badges: ['Privacy-first', 'No cookies required', 'Real-time data'],
};
export default async function FeaturesIndexPage() {
const features = featureSource;
return (
<div>
<FeatureHero hero={heroData} />
<div className="container my-16">
<WindowImage
srcDark="/screenshots/overview-dark.webp"
srcLight="/screenshots/overview-light.webp"
alt="OpenPanel Dashboard Overview"
caption="Get a clear view of your product analytics with real-time insights and customizable dashboards."
/>
</div>
<Section className="container">
<SectionHeader
title="All features"
description="Browse our capabilities. Each feature is designed to answer specific questions about your product and users."
variant="sm"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-12">
{features.map((feature) => (
<FeatureCardLink
key={feature.slug}
url={feature.url}
title={feature.hero.heading}
description={feature.hero.subheading}
/>
))}
</div>
</Section>
<CtaBanner
title="Ready to get started?"
description="Join thousands of teams using OpenPanel for their analytics needs."
ctaText="Get Started Free"
ctaLink="/onboarding"
/>
</div>
);
}

View File

@@ -1,11 +1,12 @@
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader, SectionLabel } from '@/components/section';
import { Section, SectionHeader } from '@/components/section';
import {
BarChart3Icon,
ChevronRightIcon,
DollarSignIcon,
GlobeIcon,
ZapIcon,
} from 'lucide-react';
import Link from 'next/link';
import { ProductAnalyticsIllustration } from './illustrations/product-analytics';
import { WebAnalyticsIllustration } from './illustrations/web-analytics';
@@ -15,18 +16,30 @@ const features = [
description:
'Track revenue from your payments and get insights into your revenue sources.',
icon: DollarSignIcon,
link: {
href: '/features/revenue-tracking',
children: 'More about revenue',
},
},
{
title: 'Profiles & Sessions',
description:
'Track individual users and their complete journey across your platform.',
icon: GlobeIcon,
link: {
href: '/features/identify-users',
children: 'Identify your users',
},
},
{
title: 'Event Tracking',
description:
'Capture every important interaction with flexible event tracking.',
icon: BarChart3Icon,
link: {
href: '/features/event-tracking',
children: 'All about tracking',
},
},
];
@@ -62,9 +75,19 @@ export function AnalyticsInsights() {
title={feature.title}
description={feature.description}
icon={feature.icon}
link={feature.link}
/>
))}
</div>
<p className="mt-8 text-center">
<Link
href="/features"
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors"
>
Explore all features
<ChevronRightIcon className="size-3.5" />
</Link>
</p>
</Section>
);
}

View File

@@ -4,6 +4,7 @@ import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import {
ChartBarIcon,
ChevronRightIcon,
DollarSignIcon,
LayoutDashboardIcon,
RocketIcon,
@@ -18,18 +19,21 @@ const features = [
description:
'See your data in a visual way. You can create advanced reports and more to understand',
icon: ChartBarIcon,
slug: 'data-visualization',
},
{
title: 'Share & Collaborate',
description:
'Build interactive dashboards and share insights with your team. Export reports, set up notifications, and keep everyone aligned.',
'Invite unlimited members with org-wide or project-level access. Share full dashboards or individual reports—publicly or behind a password.',
icon: LayoutDashboardIcon,
slug: 'share-and-collaborate',
},
{
title: 'Integrations',
description:
'Get notified when new events are created, or forward specific events to your own systems with our east to use integrations.',
'Get notified when new events are created, or forward specific events to your own systems with our easy-to-use integrations.',
icon: WorkflowIcon,
slug: 'integrations',
},
];
@@ -48,7 +52,11 @@ export function Collaboration() {
<div className="col gap-6 mt-16">
{features.map((feature) => (
<div className="col gap-2" key={feature.title}>
<Link
href={`/features/${feature.slug}`}
className="group relative col gap-2 pr-10 overflow-hidden"
key={feature.title}
>
<h3 className="font-semibold">
<feature.icon className="size-6 inline-block mr-2 relative -top-0.5" />
{feature.title}
@@ -56,7 +64,11 @@ export function Collaboration() {
<p className="text-muted-foreground text-sm">
{feature.description}
</p>
</div>
<ChevronRightIcon
className="absolute right-0 top-1/2 size-5 -translate-y-1/2 text-muted-foreground transition-transform duration-200 translate-x-full group-hover:translate-x-0"
aria-hidden
/>
</Link>
))}
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { getAllCompareSlugs, getCompareData } from '@/lib/compare';
import { getFeatureData } from '@/lib/features';
import { url as baseUrl } from '@/lib/layout.shared';
import { articleSource, guideSource, pageSource, source } from '@/lib/source';
import { ImageResponse } from 'next/og';
@@ -83,6 +84,22 @@ async function getOgData(
description: 'Step-by-step tutorials for adding analytics to your app',
};
}
case 'features': {
const slug = segments[1];
if (!slug) {
return {
title: 'Product analytics features',
description:
'Explore OpenPanel features: event tracking, funnels, retention, user profiles, and more.',
};
}
const featureData = await getFeatureData(slug);
return {
title: featureData?.seo.title ?? 'Feature Not Found',
description:
featureData?.seo.description ?? featureData?.hero.subheading,
};
}
case 'docs': {
const data = await source.getPage(segments.slice(1));
return {

View File

@@ -2,6 +2,7 @@ import { url } from '@/lib/layout.shared';
import {
articleSource,
compareSource,
featureSource,
guideSource,
pageSource,
source,
@@ -44,6 +45,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'weekly',
priority: 0.5,
},
{
url: url('/features'),
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: url('/supporter'),
lastModified: new Date(),
@@ -77,5 +84,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly' as const,
priority: 0.8,
})),
...featureSource.map((item) => ({
url: url(item.url),
changeFrequency: 'monthly' as const,
priority: 0.8,
})),
];
}

View File

@@ -1,7 +1,12 @@
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
import Link from 'next/link';
interface FeatureCardProps {
link?: {
href: string;
children: React.ReactNode;
};
illustration?: React.ReactNode;
title: string;
description: string;
@@ -50,6 +55,7 @@ export function FeatureCard({
icon: Icon,
children,
className,
link,
}: FeatureCardProps) {
if (illustration) {
return (
@@ -60,6 +66,14 @@ export function FeatureCard({
<p className="text-muted-foreground">{description}</p>
</div>
{children}
{link && (
<Link
className="mx-6 text-sm text-muted-foreground hover:text-primary transition-colors"
href={link.href}
>
{link.children}
</Link>
)}
</FeatureCardContainer>
);
}
@@ -72,6 +86,14 @@ export function FeatureCard({
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
{link && (
<Link
className="text-sm text-muted-foreground hover:text-primary transition-colors"
href={link.href}
>
{link.children}
</Link>
)}
</FeatureCardContainer>
);
}

View File

@@ -1,6 +1,5 @@
import { TOOLS } from '@/app/tools/tools';
import { baseOptions } from '@/lib/layout.shared';
import { articleSource, compareSource } from '@/lib/source';
import { articleSource, compareSource, featureSource } from '@/lib/source';
import { MailIcon } from 'lucide-react';
import Link from 'next/link';
import { Logo } from './logo';
@@ -31,6 +30,17 @@ export async function Footer() {
},
]}
/>
<div className="h-5" />
<h3 className="font-medium">Features</h3>
<Links
data={[
{ title: 'All features', url: '/features' },
...featureSource.map((item) => ({
title: item.short_name,
url: item.url,
})),
]}
/>
</div>
<div className="col gap-3">

View File

@@ -33,7 +33,7 @@ export function WindowImage({
return (
<FeatureCardContainer
className={cn([
'overflow-hidden rounded-lg border border-border bg-background shadow-lg/5 relative z-10 [@media(min-width:1100px)]:-mx-16 p-4 md:p-16',
'overflow-hidden rounded-lg border border-border bg-foreground/10 shadow-lg/5 relative z-10 [@media(min-width:1100px)]:-mx-16 p-4 md:p-16',
className,
])}
>

View File

@@ -0,0 +1,162 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
export interface FeatureSeo {
title: string;
description: string;
keywords?: string[];
}
export interface FeatureHero {
heading: string;
subheading: string;
badges: string[];
}
export interface FeatureDefinition {
title?: string;
text: string;
}
export interface FeatureCapability {
title: string;
description: string;
icon?: string;
}
export interface FeatureCapabilitiesSection {
title: string;
intro?: string;
}
export interface FeatureScreenshot {
src?: string;
srcDark?: string;
srcLight?: string;
alt: string;
caption?: string;
}
export interface FeatureHowItWorksStep {
title: string;
description: string;
}
export interface FeatureHowItWorks {
title: string;
intro?: string;
steps: FeatureHowItWorksStep[];
}
export interface FeatureUseCase {
title: string;
description: string;
icon?: string;
}
export interface FeatureUseCases {
title: string;
intro?: string;
items: FeatureUseCase[];
}
export interface RelatedFeature {
slug: string;
title: string;
description?: string;
}
export interface FeatureFaq {
question: string;
answer: string;
}
export interface FeatureFaqs {
title: string;
intro?: string;
items: FeatureFaq[];
}
export interface FeatureCta {
label: string;
href: string;
}
export interface FeatureData {
url: string;
slug: string;
/** Short internal name for nav, footer, etc. (e.g. "Event tracking") */
short_name: string;
seo: FeatureSeo;
hero: FeatureHero;
definition: FeatureDefinition;
capabilities: FeatureCapability[];
capabilities_section?: FeatureCapabilitiesSection;
screenshots: FeatureScreenshot[];
how_it_works?: FeatureHowItWorks;
use_cases: FeatureUseCases;
related_features: RelatedFeature[];
faqs: FeatureFaqs;
cta: FeatureCta;
}
const contentDir = join(process.cwd(), 'content', 'features');
export async function getFeatureData(
slug: string,
): Promise<FeatureData | null> {
try {
const filePath = join(contentDir, `${slug}.json`);
const fileContents = readFileSync(filePath, 'utf8');
const data = JSON.parse(fileContents) as Omit<FeatureData, 'url'>;
return {
...data,
url: `/features/${slug}`,
};
} catch (error) {
console.error(`Error loading feature data for ${slug}:`, error);
return null;
}
}
export async function getAllFeatureSlugs(): Promise<string[]> {
try {
const files = readdirSync(contentDir);
return files
.filter((file) => file.endsWith('.json'))
.map((file) => file.replace('.json', ''));
} catch (error) {
console.error('Error reading features directory:', error);
return [];
}
}
export async function loadFeatureSource(): Promise<FeatureData[]> {
const slugs = await getAllFeatureSlugs();
const results: FeatureData[] = [];
for (const slug of slugs) {
const data = await getFeatureData(slug);
if (data) results.push(data);
}
return results;
}
/** Sync loader for use in source.ts (same pattern as compareSource). */
export function loadFeatureSourceSync(): FeatureData[] {
try {
const files = readdirSync(contentDir);
return files
.filter((file) => file.endsWith('.json'))
.map((file) => {
const slug = file.replace('.json', '');
const filePath = join(contentDir, file);
const fileContents = readFileSync(filePath, 'utf8');
const data = JSON.parse(fileContents) as Omit<FeatureData, 'url'>;
return { ...data, url: `/features/${slug}` };
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
console.error('Error loading feature source:', error);
return [];
}
}

View File

@@ -11,6 +11,8 @@ import { type InferPageType, loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
import type { CompareData } from './compare';
import type { FeatureData } from './features';
import { loadFeatureSourceSync } from './features';
// See https://fumadocs.dev/docs/headless/source-api for more info
export const source = loader({
@@ -91,3 +93,5 @@ function loadCompareSource(): CompareData[] {
}
export const compareSource: CompareData[] = loadCompareSource();
export const featureSource: FeatureData[] = loadFeatureSourceSync();