docs: add llms

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-09 21:46:09 +00:00
parent 40a3178b57
commit 9f441fd9fa
11 changed files with 330 additions and 10 deletions

View File

@@ -1,3 +1,8 @@
import {
OPENPANEL_BASE_URL,
OPENPANEL_DESCRIPTION,
OPENPANEL_NAME,
} from '@/lib/openpanel-brand';
import { AnalyticsInsights } from './_sections/analytics-insights';
import { Collaboration } from './_sections/collaboration';
import { CtaBanner } from './_sections/cta-banner';
@@ -9,9 +14,34 @@ import { Sdks } from './_sections/sdks';
import { Testimonials } from './_sections/testimonials';
import { WhyOpenPanel } from './_sections/why-openpanel';
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Organization',
name: OPENPANEL_NAME,
url: OPENPANEL_BASE_URL,
sameAs: ['https://github.com/Openpanel-dev/openpanel'],
description: OPENPANEL_DESCRIPTION,
},
{
'@type': 'SoftwareApplication',
name: OPENPANEL_NAME,
applicationCategory: 'AnalyticsApplication',
operatingSystem: 'Web',
url: OPENPANEL_BASE_URL,
description: OPENPANEL_DESCRIPTION,
},
],
};
export default function HomePage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Hero />
<WhyOpenPanel />
<AnalyticsInsights />

View File

@@ -1,10 +1,27 @@
import {
OPENPANEL_BASE_URL,
OPENPANEL_DESCRIPTION,
OPENPANEL_NAME,
} from '@/lib/openpanel-brand';
import { getLLMText, source } from '@/lib/source';
export const revalidate = false;
export const dynamic = 'force-static';
const header = `# ${OPENPANEL_NAME} Full documentation for LLMs
${OPENPANEL_DESCRIPTION}
This file contains the full text of all documentation pages. Each section is separated by --- and includes a canonical URL.
`;
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
const pages = source.getPages().slice().sort((a, b) => a.url.localeCompare(b.url));
const scanned = await Promise.all(pages.map(getLLMText));
return new Response(scanned.join('\n\n'));
return new Response(header + scanned.join('\n\n'), {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
}

View File

@@ -0,0 +1,80 @@
import {
OPENPANEL_BASE_URL,
OPENPANEL_DESCRIPTION,
OPENPANEL_NAME,
} from '@/lib/openpanel-brand';
export const dynamic = 'force-static';
const body = `# ${OPENPANEL_NAME}
> ${OPENPANEL_DESCRIPTION}
## Main pages
- [Home](${OPENPANEL_BASE_URL}/)
- [Features](${OPENPANEL_BASE_URL}/features) (event tracking, funnels, retention, web analytics, and more)
- [Guides](${OPENPANEL_BASE_URL}/guides)
- [Articles](${OPENPANEL_BASE_URL}/articles)
- [Open source](${OPENPANEL_BASE_URL}/open-source)
- [Supporter](${OPENPANEL_BASE_URL}/supporter)
- [About](${OPENPANEL_BASE_URL}/about)
- [Contact](${OPENPANEL_BASE_URL}/contact)
## Core docs
- [What is OpenPanel?](${OPENPANEL_BASE_URL}/docs)
- [Install OpenPanel](${OPENPANEL_BASE_URL}/docs/get-started/install-openpanel)
- [Track Events](${OPENPANEL_BASE_URL}/docs/get-started/track-events)
- [Identify Users](${OPENPANEL_BASE_URL}/docs/get-started/identify-users)
## SDKs
- [SDKs Overview](${OPENPANEL_BASE_URL}/docs/sdks)
- [JavaScript](${OPENPANEL_BASE_URL}/docs/sdks/javascript)
- [React](${OPENPANEL_BASE_URL}/docs/sdks/react)
- [Next.js](${OPENPANEL_BASE_URL}/docs/sdks/nextjs)
- [Vue](${OPENPANEL_BASE_URL}/docs/sdks/vue)
- [React Native](${OPENPANEL_BASE_URL}/docs/sdks/react-native)
- [Swift](${OPENPANEL_BASE_URL}/docs/sdks/swift)
- [Kotlin](${OPENPANEL_BASE_URL}/docs/sdks/kotlin)
- [Python](${OPENPANEL_BASE_URL}/docs/sdks/python)
## API
- [Authentication](${OPENPANEL_BASE_URL}/docs/api/authentication)
- [Track API](${OPENPANEL_BASE_URL}/docs/api/track)
- [Export API](${OPENPANEL_BASE_URL}/docs/api/export)
- [Insights API](${OPENPANEL_BASE_URL}/docs/api/insights)
## Self-hosting
- [Self-hosting Guide](${OPENPANEL_BASE_URL}/docs/self-hosting/self-hosting)
- [Docker Compose](${OPENPANEL_BASE_URL}/docs/self-hosting/deploy-docker-compose)
- [Environment Variables](${OPENPANEL_BASE_URL}/docs/self-hosting/environment-variables)
## Pricing
- [Pricing](${OPENPANEL_BASE_URL}/pricing)
## Compare (alternatives)
- [Mixpanel alternative](${OPENPANEL_BASE_URL}/compare/mixpanel-alternative)
- [PostHog alternative](${OPENPANEL_BASE_URL}/compare/posthog-alternative)
- [Google Analytics alternative](${OPENPANEL_BASE_URL}/compare/google-analytics-alternative)
- [Amplitude alternative](${OPENPANEL_BASE_URL}/compare/amplitude-alternative)
- [Plausible alternative](${OPENPANEL_BASE_URL}/compare/plausible-alternative)
- [Umami alternative](${OPENPANEL_BASE_URL}/compare/umami-alternative)
- [Compare all](${OPENPANEL_BASE_URL}/compare)
## Trust & legal
- [Privacy Policy](${OPENPANEL_BASE_URL}/privacy)
- [Terms of Service](${OPENPANEL_BASE_URL}/terms)
## Source
- [GitHub](https://github.com/Openpanel-dev/openpanel)
## Optional
- [Full docs for LLMs](${OPENPANEL_BASE_URL}/llms-full.txt)
`;
export async function GET() {
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
}

View File

@@ -0,0 +1,151 @@
import { OPENPANEL_BASE_URL } from '@/lib/openpanel-brand';
import { articleSource, guideSource, pageSource, source } from '@/lib/source';
import { NextResponse } from 'next/server';
const ALLOWED_PAGE_PATHS = new Set([
'privacy',
'terms',
'about',
'contact',
'cookies',
]);
export const runtime = 'nodejs';
function stubMarkdown(canonicalUrl: string, path: string): string {
return `# ${path}\n\nThis page is available at: [${canonicalUrl}](${canonicalUrl})\n`;
}
async function getProcessedText(page: {
data: { getText?: (type: 'processed' | 'raw') => Promise<string> };
}): Promise<string | null> {
try {
const getText = page.data.getText;
if (typeof getText === 'function') {
return await getText('processed');
}
} catch {
// ignore
}
return null;
}
export async function GET(request: Request) {
const url = new URL(request.url);
const pathname = url.pathname;
// Rewrites preserve the original request URL, so pathname is e.g. /docs/foo.md
// Derive path from pathname when present; otherwise use query (e.g. /md?path=...)
const pathParam = pathname.endsWith('.md')
? pathname.slice(0, -3)
: url.searchParams.get('path');
if (!pathParam || pathParam.includes('..')) {
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
}
const path = pathParam.startsWith('/') ? pathParam : `/${pathParam}`;
if (path.startsWith('/docs')) {
const slug = path
.replace(/^\/docs\/?/, '')
.split('/')
.filter(Boolean);
const page = source.getPage(slug);
if (!page) {
return new NextResponse('Not found', { status: 404 });
}
const processed = await page.data.getText('processed');
const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
const body = `# ${page.data.title}\n\nURL: ${canonical}\n\n${processed}`;
return new Response(body, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
}
if (path.startsWith('/articles')) {
const slug = path
.replace(/^\/articles\/?/, '')
.split('/')
.filter(Boolean);
if (slug.length === 0)
return new NextResponse('Not found', { status: 404 });
const page = articleSource.getPage(slug);
if (!page) {
return new NextResponse('Not found', { status: 404 });
}
const text = await getProcessedText(page);
const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
const body = text
? `# ${page.data.title}\n\nURL: ${canonical}\n\n${text}`
: stubMarkdown(canonical, path);
return new Response(body, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
}
if (path.startsWith('/guides')) {
const slug = path
.replace(/^\/guides\/?/, '')
.split('/')
.filter(Boolean);
if (slug.length === 0)
return new NextResponse('Not found', { status: 404 });
const page = guideSource.getPage(slug);
if (!page) {
return new NextResponse('Not found', { status: 404 });
}
const text = await getProcessedText(page);
const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
const body = text
? `# ${page.data.title}\n\nURL: ${canonical}\n\n${text}`
: stubMarkdown(canonical, path);
return new Response(body, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
}
if (
path === '/' ||
(path.startsWith('/') && path.split('/').filter(Boolean).length === 1)
) {
const segment = path.replace(/^\//, '');
const slug = segment ? [segment] : [];
const page = slug.length ? pageSource.getPage(slug) : null;
if (page) {
try {
const getText = (
page.data as { getText?: (mode: string) => Promise<string> }
).getText;
if (typeof getText === 'function') {
const processed = await getText('processed');
const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
const body = `# ${page.data.title}\n\nURL: ${canonical}\n\n${processed}`;
return new Response(body, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
}
} catch {
// fall through to stub if getText not available
}
if (ALLOWED_PAGE_PATHS.has(segment)) {
return new Response(
stubMarkdown(`${OPENPANEL_BASE_URL}/${segment}`, path),
{
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
},
);
}
}
if (ALLOWED_PAGE_PATHS.has(segment)) {
return new Response(
stubMarkdown(`${OPENPANEL_BASE_URL}/${segment}`, path),
{
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
},
);
}
}
return new NextResponse('Not found', { status: 404 });
}

View File

@@ -1,4 +1,8 @@
User-Agent: *
Allow: /
Allow: /og*
Allow: /llms.txt
Allow: /llms-full.txt
Allow: /*.md
Sitemap: https://openpanel.dev/sitemap.xml

View File

@@ -57,6 +57,18 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: url('/llms.txt'),
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.3,
},
{
url: url('/llms-full.txt'),
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.3,
},
...articles.map((item) => ({
url: url(item.url),
lastModified: item.data.date,

View File

@@ -1,9 +1,10 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { OPENPANEL_BASE_URL, OPENPANEL_NAME } from './openpanel-brand';
export const siteName = 'OpenPanel';
export const siteName = OPENPANEL_NAME;
export const baseUrl =
process.env.NODE_ENV === 'production'
? 'https://openpanel.dev'
? OPENPANEL_BASE_URL
: 'http://localhost:3000';
export const url = (path: string) => {
if (path.startsWith('http')) {

View File

@@ -1,9 +1,9 @@
import type { Metadata } from 'next';
import { OPENPANEL_DESCRIPTION, OPENPANEL_NAME } from './openpanel-brand';
import { url as baseUrl } from './layout.shared';
const siteName = 'OpenPanel';
const defaultDescription =
'OpenPanel is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.';
const siteName = OPENPANEL_NAME;
const defaultDescription = OPENPANEL_DESCRIPTION;
const defaultImage = baseUrl('/ogimage.png');
export function getOgImageUrl(url: string): string {

View File

@@ -0,0 +1,4 @@
export const OPENPANEL_NAME = 'OpenPanel';
export const OPENPANEL_BASE_URL = 'https://openpanel.dev';
export const OPENPANEL_DESCRIPTION =
'OpenPanel is an open-source web and product analytics platform, an open-source alternative to Mixpanel with optional self-hosting.';

View File

@@ -10,6 +10,7 @@ import {
import { type InferPageType, loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
import { OPENPANEL_BASE_URL } from './openpanel-brand';
import type { CompareData } from './compare';
import type { FeatureData } from './features';
import { loadFeatureSourceSync } from './features';
@@ -49,8 +50,11 @@ export function getPageImage(page: InferPageType<typeof source>) {
export async function getLLMText(page: InferPageType<typeof source>) {
const processed = await page.data.getText('processed');
const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
return `# ${page.data.title}
return `---
## ${page.data.title}
URL: ${canonical}
${processed}`;
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.endsWith('.md')) {
const url = request.nextUrl.clone();
url.pathname = '/md';
url.searchParams.set('path', pathname.slice(0, -3));
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next|api|favicon|robots|sitemap|og).*\\.md)'],
};