From 54cb35f19090d3226cd1b75f9e9a57482a35a2b3 Mon Sep 17 00:00:00 2001 From: Pan YANG Date: Thu, 13 Aug 2026 12:54:57 +0800 Subject: [PATCH 1/2] feat(home): rebuild schema-backed homepage --- data/$schemas/homepage.schema.json | 38 ++ data/homepage.json | 29 + docs/I18N-ARCHITECTURE-RULES.md | 12 + src/app/[locale]/globals.css | 35 ++ src/app/[locale]/page.client.tsx | 618 +++++++++++++++++++++ src/app/[locale]/page.tsx | 343 ++++++++---- src/lib/homepage-data.ts | 110 ++++ tests/model-intelligence-index.test.ts | 14 + tests/validate/i18n-typography.test.ts | 19 + tests/validate/ranking-data.schema.test.ts | 2 +- translations/de/pages/home.json | 41 +- translations/en/pages/home.json | 41 +- translations/es/pages/home.json | 41 +- translations/fr/pages/home.json | 41 +- translations/id/pages/home.json | 41 +- translations/ja/pages/home.json | 41 +- translations/ko/pages/home.json | 41 +- translations/pt/pages/home.json | 41 +- translations/ru/pages/home.json | 41 +- translations/tr/pages/home.json | 41 +- translations/zh-Hans/pages/home.json | 41 +- translations/zh-Hant/pages/home.json | 41 +- 22 files changed, 1530 insertions(+), 182 deletions(-) create mode 100644 data/$schemas/homepage.schema.json create mode 100644 data/homepage.json create mode 100644 src/app/[locale]/page.client.tsx create mode 100644 src/lib/homepage-data.ts create mode 100644 tests/validate/i18n-typography.test.ts diff --git a/data/$schemas/homepage.schema.json b/data/$schemas/homepage.schema.json new file mode 100644 index 00000000..6f57cc90 --- /dev/null +++ b/data/$schemas/homepage.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Homepage Configuration", + "description": "Curated data selections used by the homepage.", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "modelIntelligenceSeries": { + "type": "array", + "items": { + "$ref": "#/$defs/modelIntelligenceSeries" + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["$schema", "modelIntelligenceSeries"], + "additionalProperties": false, + "$defs": { + "modelIntelligenceSeries": { + "type": "object", + "properties": { + "vendor": { + "type": "string", + "minLength": 1 + }, + "seriesId": { + "type": "string", + "pattern": "^[a-z0-9-]+$" + } + }, + "required": ["vendor", "seriesId"], + "additionalProperties": false + } + } +} diff --git a/data/homepage.json b/data/homepage.json new file mode 100644 index 00000000..e4b960d8 --- /dev/null +++ b/data/homepage.json @@ -0,0 +1,29 @@ +{ + "$schema": "./$schemas/homepage.schema.json", + "modelIntelligenceSeries": [ + { + "vendor": "Anthropic", + "seriesId": "claude-opus" + }, + { + "vendor": "OpenAI", + "seriesId": "gpt-sol" + }, + { + "vendor": "Moonshot", + "seriesId": "kimi" + }, + { + "vendor": "MiniMax", + "seriesId": "minimax-m" + }, + { + "vendor": "DeepSeek", + "seriesId": "deepseek" + }, + { + "vendor": "Z.ai", + "seriesId": "glm" + } + ] +} diff --git a/docs/I18N-ARCHITECTURE-RULES.md b/docs/I18N-ARCHITECTURE-RULES.md index d19a3de9..1ee6bd02 100644 --- a/docs/I18N-ARCHITECTURE-RULES.md +++ b/docs/I18N-ARCHITECTURE-RULES.md @@ -505,6 +505,18 @@ function Breadcrumb() { --- +## Chinese Typography + +- Simplified and Traditional Chinese interfaces automatically add typographic spacing between + Han characters and adjacent half-width Latin letters or Arabic numerals with + `text-autospace: normal` on the document language root. +- This global rule also covers generated values such as localized dates and counts. Do not add + literal ASCII spaces to translations or formatters solely to create this visual spacing. +- Literal content in `code`, `pre`, `kbd`, and `samp` is excluded so commands and source text keep + their exact spacing. + +--- + ## Conclusion These rules establish a clear, scalable architecture for i18n translations that: diff --git a/src/app/[locale]/globals.css b/src/app/[locale]/globals.css index d04aca6a..134b3e6e 100644 --- a/src/app/[locale]/globals.css +++ b/src/app/[locale]/globals.css @@ -54,6 +54,19 @@ a { font-weight: 400; } +/* Keep Han text readable next to half-width letters and numerals, including dynamic dates. */ +html:lang(zh) { + text-autospace: normal; +} + +/* Literal text must retain its exact spacing. */ +code, +pre, +kbd, +samp { + text-autospace: no-autospace; +} + h1, h2, h3, @@ -128,6 +141,28 @@ strong { animation: fadeIn 200ms ease-out; } +.homepage-activity-list { + scrollbar-color: var(--color-border-strong) transparent; + scrollbar-gutter: stable; + scrollbar-width: thin; +} + +.homepage-activity-list::-webkit-scrollbar { + width: 6px; +} + +.homepage-activity-list::-webkit-scrollbar-track { + background: var(--color-hover); +} + +.homepage-activity-list::-webkit-scrollbar-thumb { + background: var(--color-border-strong); +} + +.homepage-price-chart .recharts-surface { + overflow: visible; +} + /* cmdk overrides */ [cmdk-root] { font-family: var(--font-ibm-plex-mono), monospace; diff --git a/src/app/[locale]/page.client.tsx b/src/app/[locale]/page.client.tsx new file mode 100644 index 00000000..d2a13eaf --- /dev/null +++ b/src/app/[locale]/page.client.tsx @@ -0,0 +1,618 @@ +'use client' + +import { + ChevronLeft, + ChevronRight, + Code2, + Cpu, + ExternalLink, + Monitor, + Puzzle, + Server, + Terminal, +} from 'lucide-react' +import { useTranslations } from 'next-intl' +import { useEffect, useMemo, useState } from 'react' +import { + CartesianGrid, + LabelList, + Line, + LineChart, + ResponsiveContainer, + Scatter, + ScatterChart, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { ModelChartLabel, ModelChartPoint } from '@/components/charts/ModelChartLabel' +import { useTheme } from '@/components/ThemeProvider' +import { Link } from '@/i18n/navigation' +import type { HomepageActivity } from '@/lib/homepage-data' + +interface HomepageSeriesPoint { + name: string + score: number + timestamp: number +} + +interface HomepageSeries { + color: { dark: string; light: string } + id: string + name: string + points: HomepageSeriesPoint[] +} + +interface HomepagePricePoint { + color: { dark: string; light: string } + labelAnchor: 'start' | 'middle' | 'end' + labelDx: number + labelDy: number + modelId: string + name: string + price: number + score: number + vendor: string +} + +interface HomepageDataStageProps { + activities: HomepageActivity[] + intelligenceAxisHint: string + intelligenceSeries: HomepageSeries[] + intelligenceTitle: string + locale: string + observedAt: string + priceAxisHint: string + priceBlendedLabel: string + priceIndexLabel: string + pricePoints: HomepagePricePoint[] + priceTitle: string + stats: { + lastUpdated: string + records: number + sources: number + verified: number + } +} + +const CAROUSEL_INTERVAL = 8000 +const ACTIVITY_ICONS = { + cli: Terminal, + desktop: Monitor, + extension: Puzzle, + ide: Code2, + model: Cpu, + provider: Server, +} satisfies Record + +function formatDate(value: string | number, locale: string): string { + const date = typeof value === 'number' ? new Date(value) : new Date(`${value}T00:00:00Z`) + + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + timeZone: 'UTC', + year: 'numeric', + }).format(date) +} + +function formatShortDate(value: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { + month: 'short', + timeZone: 'UTC', + year: '2-digit', + }).format(new Date(value)) +} + +function formatActivityDate(value: string, locale: string): string { + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + timeZone: 'UTC', + }).format(new Date(`${value}T00:00:00Z`)) +} + +function formatPriceValue(value: number, locale: string): string { + return new Intl.NumberFormat(locale, { + minimumFractionDigits: value < 1 ? 2 : 0, + maximumFractionDigits: 2, + }).format(value) +} + +export function HomepageDataStage({ + activities, + intelligenceAxisHint, + intelligenceSeries, + intelligenceTitle, + locale, + observedAt, + priceAxisHint, + priceBlendedLabel, + priceIndexLabel, + pricePoints, + priceTitle, + stats, +}: HomepageDataStageProps) { + const tPage = useTranslations('pages.home') + const { theme } = useTheme() + const [activeIndex, setActiveIndex] = useState(0) + const [hoveredPricePoint, setHoveredPricePoint] = useState(null) + const [hoveredPriceVendor, setHoveredPriceVendor] = useState(null) + const [isPaused, setIsPaused] = useState(false) + const [prefersReducedMotion, setPrefersReducedMotion] = useState(true) + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') + const updatePreference = () => setPrefersReducedMotion(mediaQuery.matches) + + updatePreference() + mediaQuery.addEventListener('change', updatePreference) + + return () => mediaQuery.removeEventListener('change', updatePreference) + }, []) + + useEffect(() => { + if (isPaused || prefersReducedMotion) return + + const timer = window.setInterval(() => { + setActiveIndex(current => (current + 1) % 2) + }, CAROUSEL_INTERVAL) + + return () => window.clearInterval(timer) + }, [isPaused, prefersReducedMotion]) + + const timelineData = useMemo(() => { + const pointsByTimestamp = new Map>() + + for (const series of intelligenceSeries) { + for (const point of series.points) { + const existing = pointsByTimestamp.get(point.timestamp) ?? {} + existing[series.id] = point.score + existing[`${series.id}:model`] = point.name + pointsByTimestamp.set(point.timestamp, existing) + } + } + + return Array.from(pointsByTimestamp, ([timestamp, scores]) => ({ timestamp, ...scores })).sort( + (first, second) => first.timestamp - second.timestamp + ) + }, [intelligenceSeries]) + + const pricePointsByVendor = useMemo(() => { + const groups = new Map() + + for (const point of pricePoints) { + const vendorPoints = groups.get(point.vendor) ?? [] + vendorPoints.push(point) + groups.set(point.vendor, vendorPoints) + } + + return Array.from(groups.entries()) + }, [pricePoints]) + + const chartTextColor = theme === 'dark' ? '#b8b8b8' : '#4a4a4a' + const chartGridColor = theme === 'dark' ? '#3a3a3a' : '#e3e3e3' + const slides = [ + { + href: '/model-intelligence-index' as const, + title: intelligenceTitle, + }, + { + href: '/model-price-intelligence-index' as const, + title: priceTitle, + }, + ] + + const showPrevious = () => { + setActiveIndex(current => (current + 1) % 2) + setIsPaused(true) + } + const showNext = () => { + setActiveIndex(current => (current + 1) % 2) + setIsPaused(true) + } + + return ( +
+
+
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + onFocus={() => setIsPaused(true)} + onBlur={() => setIsPaused(false)} + > +
+
+ {slides.map((slide, index) => ( + + ))} +
+ +
+ + {tPage('dataStage.counter', { current: activeIndex + 1, total: slides.length })} + + + +
+
+ +
+
+
+

+ {slides[activeIndex]?.title} +

+

+ {activeIndex === 0 ? intelligenceAxisHint : priceAxisHint} +

+
+ + {tPage('dataStage.viewIndex')} +
+
+ +
+ {activeIndex === 0 ? ( +
+
+ + + + formatShortDate(Number(value), locale)} + type="number" + /> + + { + const entries = payload?.filter(entry => typeof entry.value === 'number') + + if (!active || !entries?.length) return null + + return ( +
+
+ {formatDate(Number(label), locale)} +
+
    + {entries.map(entry => { + const dataKey = String(entry.dataKey) + const row = entry.payload as Record + const modelName = row[`${dataKey}:model`] + + return ( +
  • +
    + {typeof modelName === 'string' ? modelName : entry.name} +
    +
    + {entry.name} + + {priceIndexLabel} {entry.value} + +
    +
  • + ) + })} +
+
+ ) + }} + /> + {intelligenceSeries.map(series => ( + + ))} +
+
+
+
    + {intelligenceSeries.map(series => ( +
  • +
  • + ))} +
+
+ ) : ( + // biome-ignore lint/a11y/noStaticElementInteractions: Mouse leave only clears transient chart labels. +
{ + setHoveredPricePoint(null) + setHoveredPriceVendor(null) + }} + > +
+ + + + formatPriceValue(Number(value), locale)} + type="number" + unit=" USD" + /> + + {pricePointsByVendor.map(([vendor, vendorPoints]) => ( + { + const point = shapeProps.payload as HomepagePricePoint + const showPoint = () => { + setHoveredPricePoint(point) + setHoveredPriceVendor(vendor) + } + + return ( + // biome-ignore lint/a11y/useSemanticElements: SVG chart points cannot render HTML buttons. + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + showPoint() + } + }} + onMouseEnter={showPoint} + > + + + ) + }} + > + { + if (hoveredPriceVendor !== vendor) return + + const point = vendorPoints[labelProps.index ?? 0] + if (!point) return + + return ( + + ) + }} + /> + + ))} + + + {hoveredPricePoint ? ( +
+
+ {hoveredPricePoint.name} +
+
+ {hoveredPricePoint.vendor} +
+
+
{priceBlendedLabel}
+
+ {formatPriceValue(hoveredPricePoint.price, locale)} USD +
+
{priceIndexLabel}
+
+ {hoveredPricePoint.score} +
+
+
+ ) : null} +
+
    + {pricePointsByVendor.map(([vendor, vendorPoints]) => ( +
  • +
  • + ))} +
+
+ )} +
+ +
+ {tPage('dataStage.observed', { date: formatDate(observedAt, locale) })} + +
+
+ + +
+ +
+ {( + [ + ['records', stats.records], + ['verified', stats.verified], + ['sources', stats.sources], + ['lastUpdated', formatDate(stats.lastUpdated, locale)], + ] as const + ).map(([key, value]) => ( +
+
{tPage(`proof.${key}`)}
+
{value}
+
+ ))} +
+
+ ) +} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index e0fcc900..6f02d154 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,3 +1,13 @@ +import { + ArrowRight, + Blocks, + ChartNoAxesCombined, + Clock3, + GitCompareArrows, + Github, + Network, + Scale, +} from 'lucide-react' import { getTranslations } from 'next-intl/server' import Footer from '@/components/Footer' import Header from '@/components/Header' @@ -6,12 +16,62 @@ import { MarkdownContent } from '@/components/MarkdownContent' import type { Locale } from '@/i18n/config' import { Link } from '@/i18n/navigation' import { faqMetadata } from '@/lib/generated/metadata' +import { homepageActivities, homepageStats } from '@/lib/homepage-data' import { buildTitle, generateStaticPageMetadata } from '@/lib/metadata' import { generateFAQPageSchema } from '@/lib/metadata/schemas' +import { + allModelIntelligencePoints, + modelIntelligenceMeta, + modelIntelligenceSeries, +} from '@/lib/model-intelligence-index' +import { modelPriceIntelligencePoints } from '@/lib/model-price-intelligence-index' import type { LocalePageProps } from '@/types/locale' +import homepageData from '../../../data/homepage.json' +import { HomepageDataStage } from './page.client' export const revalidate = 3600 +const homepageIntelligenceSeries = homepageData.modelIntelligenceSeries.map(selection => { + const series = modelIntelligenceSeries.find( + candidate => + candidate.vendor === selection.vendor && + candidate.id === `${selection.vendor}:${selection.seriesId}` + ) + + if (!series || series.points.length < 2) { + throw new Error( + `Homepage Intelligence Index series is missing or has no timeline: ${selection.vendor}:${selection.seriesId}` + ) + } + + const name = series.name.toLowerCase().startsWith(series.vendor.toLowerCase()) + ? series.name + : `${series.vendor} ${series.name}` + + return { + color: series.color, + id: series.id, + name, + points: series.points.map(point => ({ + name: point.name, + score: point.score, + timestamp: point.timestamp, + })), + } +}) + +const homepagePricePoints = modelPriceIntelligencePoints.map(point => ({ + color: point.color, + labelAnchor: point.labelAnchor, + labelDx: point.labelDx, + labelDy: point.labelDy, + modelId: point.modelId, + name: point.name, + price: point.blendedPrice, + score: point.score, + vendor: point.vendor, +})) + export async function generateMetadata({ params }: LocalePageProps) { const { locale } = await params const tPage = await getTranslations({ locale, namespace: 'pages.home.meta' }) @@ -32,7 +92,6 @@ export async function generateMetadata({ params }: LocalePageProps) { async function getFaqSchema(locale: string) { const faqItems = faqMetadata[locale] || faqMetadata.en || [] - // Use the new schema generator return await generateFAQPageSchema( faqItems.map(faq => ({ question: faq.title, @@ -44,147 +103,213 @@ async function getFaqSchema(locale: string) { export default async function Home({ params }: LocalePageProps) { const { locale } = await params const tPage = await getTranslations({ locale, namespace: 'pages.home' }) - const tShared = await getTranslations({ locale, namespace: 'shared' }) + const tIntelligence = await getTranslations({ + locale, + namespace: 'pages.modelIntelligenceIndex', + }) + const tPrice = await getTranslations({ + locale, + namespace: 'pages.modelPriceIntelligenceIndex', + }) + const tOpenSource = await getTranslations({ locale, namespace: 'pages.openSourceRank' }) const faqItems = faqMetadata[locale] || faqMetadata.en || [] const faqSchema = await getFaqSchema(locale) + const topics = [ + { + description: tIntelligence('description'), + href: '/model-intelligence-index' as const, + icon: ChartNoAxesCombined, + metric: tPage('topics.indexedModels', { count: allModelIntelligencePoints.length }), + title: tIntelligence('title'), + }, + { + description: tPrice('description'), + href: '/model-price-intelligence-index' as const, + icon: Scale, + metric: tPage('topics.comparableModels', { count: modelPriceIntelligencePoints.length }), + title: tPrice('title'), + }, + { + description: tOpenSource('description'), + href: '/open-source-rank' as const, + icon: Github, + metric: tPage('topics.openSourceRepositories', { + count: homepageStats.openSourceRepositories, + }), + title: tOpenSource('title'), + }, + ] + + const featureLinks = { + comparison: { href: '/clis/comparison' as const, icon: GitCompareArrows }, + directory: { href: '/ai-coding-stack' as const, icon: Blocks }, + ecosystem: { href: '/ai-coding-landscape' as const, icon: Network }, + tracking: { href: '/open-source-rank' as const, icon: Clock3 }, + } + return ( <>
- {/* Hero Section */} -
-
-
- - -

+
+
+
+

+ {tPage('subtitle')} +

+

{tPage('title')}

- -

- {tPage('subtitle')} -
+

{tPage('description')}

+
+
- {/* CTA Section */} -
- - {tShared('actions.explore')} - - - {tPage('readDocs')} - +
+ +
+ +
+
+
+

+ {tPage('topics.eyebrow')} +

+

+ {tPage('topics.title')} +

-
-

- - {/* Features Section */} -
-
-
-

+ +
+ {topics.map(topic => { + const Icon = topic.icon + + return ( +
+
+

{topic.metric}

+
+

{topic.title}

+

+ {topic.description} +

+ + {tPage('topics.view')} +
+ ) + })} +
+

+ +
+
+

{tPage('features.title')}

-
+
{(['directory', 'comparison', 'ecosystem', 'tracking'] as const).map(featureKey => { - const iconMap: Record = { - directory: 'DIR', - comparison: 'CMP', - ecosystem: 'ECO', - tracking: 'TRK', - } + const feature = featureLinks[featureKey] + const Icon = feature.icon return ( -
-
-                      {`┌─────┐
-│ ${iconMap[featureKey]} │
-└─────┘`}
-                    
-

+
+
+

{tPage(`features.${featureKey}.title`)}

-

+

{tPage(`features.${featureKey}.description`)}

-
+ ) })}
-
-
- - {/* FAQ Section */} -
-

- {tPage('faq')} -

-
- {faqItems.map(faq => ( -
-
- - - ▶ - -

- {faq.title} -

-
-
+
+ +
+

+ {tPage('faq')} +

+
+ {faqItems.map(faq => ( +
+
+ + + ▶ + +

+ {faq.title} +

+
- +
+ +
-
- - - ))} - -
+ + + ))} + + +