Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/app/_components/site-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export function SiteFooter() {
<footer className="mt-auto border-t border-kumo-hairline">
<div className="mx-auto flex w-full max-w-6xl items-center justify-center px-6 py-6">
<Text variant="secondary" size="sm">
vinext is an open-source{" "}
vinext.dev is the official website for vinext, an open-source{" "}
<a
href="https://www.cloudflare.com/"
className="underline underline-offset-2 hover:text-kumo-default"
Expand Down
6 changes: 5 additions & 1 deletion apps/web/app/_components/site-header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { buttonVariants } from "@cloudflare/kumo/components/button";
import { GaugeIcon, GithubLogoIcon, GraphIcon } from "@phosphor-icons/react/dist/ssr";
import { BookOpenIcon, GaugeIcon, GithubLogoIcon, GraphIcon } from "@phosphor-icons/react/dist/ssr";
import Link from "next/link";

const navButton = buttonVariants({ variant: "ghost", size: "sm" });
Expand All @@ -14,6 +14,10 @@ export function SiteHeader() {
<span className="font-semibold tracking-tight text-kumo-default">vinext</span>
</Link>
<nav className="flex items-center gap-2">
<Link href="/readme" className={navButton}>
<BookOpenIcon />
Docs
</Link>
<Link href="/compatibility" className={navButton}>
<GraphIcon />
Compatibility
Expand Down
12 changes: 12 additions & 0 deletions apps/web/app/_components/structured-data.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Emits a schema.org graph as JSON-LD. Server component — the script must be in
* the initial HTML, not injected on hydration, or crawlers never see it.
*/
export function StructuredData({ graph }: { graph: unknown }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(graph) }}
/>
);
}
102 changes: 102 additions & 0 deletions apps/web/app/_lib/structured-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Shared schema.org graph for vinext.dev.
*
* The site competes for the "vinext" query against community mirrors that
* publish richer entity markup than we do. Every node here is stably `@id`'d so
* the whole site describes one entity rather than three unrelated graphs, and
* `sameAs` enumerates the corroborating profiles (repo, announcement, npm) that
* only the official site can legitimately claim.
*/

const SITE_URL = "https://vinext.dev";
const REPO_URL = "https://github.com/cloudflare/vinext";
const ANNOUNCEMENT_URL = "https://blog.cloudflare.com/vinext/";
const CLOUDFLARE_URL = "https://www.cloudflare.com";

const ORGANIZATION_ID = `${SITE_URL}/#organization`;
const SOFTWARE_ID = `${SITE_URL}/#software`;
const WEBSITE_ID = `${SITE_URL}/#website`;

const SITE_NAME = "vinext";
export const SITE_DESCRIPTION =
"Take any Next.js app and deploy it anywhere with one command. App Router, Pages Router, RSC, ISR — all on Vite.";

type JsonLdNode = Record<string, unknown>;

const organization: JsonLdNode = {
"@type": "Organization",
"@id": ORGANIZATION_ID,
name: "Cloudflare",
url: CLOUDFLARE_URL,
sameAs: [
"https://github.com/cloudflare",
"https://www.wikidata.org/wiki/Q2748854",
"https://en.wikipedia.org/wiki/Cloudflare",
],
};

const website: JsonLdNode = {
"@type": "WebSite",
"@id": WEBSITE_ID,
name: SITE_NAME,
alternateName: "vinext — the official website",
description: SITE_DESCRIPTION,
url: `${SITE_URL}/`,
inLanguage: "en",
publisher: { "@id": ORGANIZATION_ID },
about: { "@id": SOFTWARE_ID },
};

const software: JsonLdNode = {
"@type": "SoftwareApplication",
"@id": SOFTWARE_ID,
name: SITE_NAME,
description: SITE_DESCRIPTION,
url: `${SITE_URL}/`,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
license: `${REPO_URL}/blob/main/LICENSE`,
author: { "@id": ORGANIZATION_ID },
creator: { "@id": ORGANIZATION_ID },
publisher: { "@id": ORGANIZATION_ID },
sameAs: [REPO_URL, ANNOUNCEMENT_URL, "https://www.npmjs.com/package/vinext"],
};

const sourceCode: JsonLdNode = {
"@type": "SoftwareSourceCode",
name: SITE_NAME,
description: SITE_DESCRIPTION,
url: `${SITE_URL}/`,
codeRepository: REPO_URL,
programmingLanguage: "TypeScript",
license: `${REPO_URL}/blob/main/LICENSE`,
author: { "@id": ORGANIZATION_ID },
isPartOf: { "@id": SOFTWARE_ID },
};

/** Rendered once per page from the root layout. */
export const siteGraph = {
"@context": "https://schema.org",
"@graph": [organization, website, software, sourceCode],
};

/**
* Breadcrumbs for a second-level page. The home crumb is always first so every
* subpage restates vinext.dev as the root of the entity.
*/
export function breadcrumbGraph(name: string, path: string) {
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "BreadcrumbList",
"@id": `${SITE_URL}${path}#breadcrumb`,
itemListElement: [
{ "@type": "ListItem", position: 1, name: SITE_NAME, item: `${SITE_URL}/` },
{ "@type": "ListItem", position: 2, name, item: `${SITE_URL}${path}` },
],
},
],
};
}
4 changes: 3 additions & 1 deletion apps/web/app/benchmarks/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export function createBenchmarkMetadata({
path,
index = true,
}: BenchmarkMetadataOptions): Metadata {
// The root layout templates the document title as `%s — vinext`; openGraph and
// twitter titles are not templated, so they still need the brand spelled out.
const brandedTitle = `${title} — vinext`;
return {
title: brandedTitle,
title,
description,
alternates: { canonical: path },
robots: index ? undefined : { index: false, follow: true },
Expand Down
16 changes: 11 additions & 5 deletions apps/web/app/benchmarks/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { Metadata } from "next";
import { Dashboard } from "./components/dashboard";
import { getPerformanceRuns } from "@/app/lib/benchmarks/server";
import { StructuredData } from "@/app/_components/structured-data";
import { breadcrumbGraph } from "@/app/_lib/structured-data";

export const revalidate = 300;

const title = "Performance benchmarks — vinext";
// Bare title: the root layout's `%s — vinext` template supplies the suffix.
// openGraph/twitter titles are not templated, so they carry the brand explicitly.
const title = "Performance benchmarks";
const brandedTitle = `${title} — vinext`;
const description =
"Compare vinext and Next.js production build time, dev server startup, and bundle size.";

Expand All @@ -18,23 +23,24 @@ export const metadata: Metadata = {
type: "website",
locale: "en_US",
siteName: "vinext",
title,
title: brandedTitle,
description,
url: "/benchmarks",
},
};

/**
* Homepage — server component shell.
* /benchmarks — server component shell.
* The interactive dashboard (tabs, charts, data fetching) is a client component.
*/
export default async function HomePage() {
export default async function BenchmarksPage() {
const runs = await getPerformanceRuns();

return (
<div className="mx-auto w-full max-w-6xl px-6 py-8">
<StructuredData graph={breadcrumbGraph(title, "/benchmarks")} />
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight">Performance Dashboard</h1>
<h1 className="text-3xl font-semibold tracking-tight">vinext performance benchmarks</h1>
<p className="mt-1 text-sm text-gray-500">
Benchmarks run on every merge to main. Comparing Next.js (Turbopack) vs vinext (Vite 8).
</p>
Expand Down
101 changes: 101 additions & 0 deletions apps/web/app/compatibility/compat-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Shared vocabulary for the compatibility views.
*
* These live in a plain module rather than in contribution-grid.tsx because
* that file is a client component: importing values from it into the server
* component turns them into client-reference proxies that throw when called.
* The server-rendered results table in page.tsx needs the same labels the grid
* uses, so they belong on this side of the boundary.
*/
import type { FileStatus, RouterKind } from "@/app/lib/db/schema";
import { VITE_EQUIVALENT_LABEL, type SuiteSupportStatus } from "./suite-support";

export type GridCell = {
suite: string;
status: FileStatus;
router: RouterKind;
supportStatus: SuiteSupportStatus;
feature: string | null;
reason: string | null;
total: number;
passed: number;
failed: number;
skipped: number;
};

export type DisplayStatus = FileStatus | Exclude<SuiteSupportStatus, "supported">;

export const LABELS: Record<DisplayStatus, string> = {
pass: "Pass",
partial: "Partial",
fail: "Fail",
skip: "Skipped by Next.js",
deferred: "Deferred",
"needs-vite-equivalent": VITE_EQUIVALENT_LABEL,
unsupported: "Unsupported by vinext",
};

export const SUPPORT_LABELS: Record<SuiteSupportStatus, string> = {
supported: "Supported",
deferred: "Deferred",
"needs-vite-equivalent": VITE_EQUIVALENT_LABEL,
unsupported: "Unsupported by vinext",
};

export const ROUTER_LABELS: Record<RouterKind, string> = {
app: "App Router",
pages: "Pages Router",
both: "Mixed (App + Pages)",
unknown: "No router fixture",
};

/**
* The compatibility-scope classification wins over the raw test result, so a
* deliberately deferred suite does not read as a failure.
*/
export function getDisplayStatus(cell: GridCell): DisplayStatus {
return cell.supportStatus === "supported" ? cell.status : cell.supportStatus;
}

/**
* Groups a test file by the part of the Next.js suite it belongs to:
*
* test/e2e/app-dir/foo.test.ts → "app-dir"
* test/e2e/middleware/foo.test.ts → "middleware"
* test/e2e/foo.test.ts → "e2e"
* test/integration/foo.test.ts → "integration"
* test/unit/foo.test.ts → "unit"
*
* Returns null when the path has been collapsed to a basename (older reports
* that don't preserve path info) — the caller hides the row.
*/
export function deriveSuiteGroup(suite: string): string | null {
if (!suite.includes("/")) return null;
const parts = suite.split("/").filter(Boolean);
// Strip a leading "test/" if present.
const start = parts[0] === "test" ? 1 : 0;
const first = parts[start];
if (!first) return null;
// For test/e2e/<group>/file or test/integration/<group>/file, use the
// sub-group when there is one beyond the leaf file. Otherwise fall back
// to the top-level directory (e.g. "e2e", "integration").
if (parts.length - start >= 3) return parts[start + 1];
return first;
}

export function summarize(cell: GridCell): string {
const parts = [`${cell.passed}/${cell.total} passed`];
if (cell.failed > 0) parts.push(`${cell.failed} failed`);
if (cell.skipped > 0) parts.push(`${cell.skipped} skipped`);
const group = deriveSuiteGroup(cell.suite);
const prefix = group ? `[${group}] ${cell.suite}` : cell.suite;
const routerTag = ROUTER_LABELS[cell.router];
const displayStatus = getDisplayStatus(cell);
const rawStatus = displayStatus === cell.status ? "" : ` · raw result: ${LABELS[cell.status]}`;
return `${prefix} — ${LABELS[displayStatus]}${rawStatus} · ${routerTag} (${parts.join(", ")})`;
}

/** Groups a cell for the results table: its feature label, else its suite group. */
export function groupKey(cell: GridCell): string {
return cell.feature ?? deriveSuiteGroup(cell.suite) ?? "Other";
}
Loading
Loading