diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be8a5748..ff021f8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,6 +133,35 @@ Adding a new framework increases the maintenance burden, so please open an issue 6. **Submit a PR**: Open a pull request with the new packages and configuration. Once merged, the CI will automatically pick up the new framework and raise a PR with new metrics. +### Adding a Code Comparison Example + +The [Code Comparison](https://frameworks.e18e.dev/code-comparison/) page is driven by markdown files in `packages/docs/src/content/code-comparison//.md`. One file per framework, named after the framework's slug, which is its `packages/docs/src/content/devtime/starter-*.json` filename without the prefix. Anything else under that folder fails `pnpm type-check`, including a stray file at the wrong depth or with the wrong extension. A framework with no file gets a placeholder tab, so a partial example still ships. + +Each file holds optional frontmatter, optional prose, and one fenced code block per file the task needs: + +````md +--- +docs: https://svelte.dev/docs/kit/routing +--- + +```svelte title="src/routes/about/+page.svelte" +

About

+``` +```` + +The rules the page depends on: + +- `title=` is the path relative to the starter package root, and the block is the **complete** file at that path. A reader must be able to write every block verbatim into a fresh copy of `packages/starter-` and have the example work with no other edits and no added dependencies. Replace a file the starter already ships rather than showing a diff of it. +- Keep snippets minimal. No layouts, styling, metadata, or test ids. +- Prose is for a step the framework needs beyond adding a file. One or two sentences. +- `docs` is optional and must be a working URL to that framework's guide for the task. + +Adding a new example means adding the folder plus a `##` heading and a `` line in `packages/docs/src/content/docs/code-comparison.mdx`. Nothing else changes, so examples can be contributed in parallel. + +Verify a snippet before opening the PR, because nothing in CI can. Copy the starter to a scratch directory, `pnpm install`, write every block into it at its `title=` path, run `pnpm dev`, and request the routes the example adds. + +Run `pnpm format` too. Prettier reformats `astro`, `vue`, `ts`, and `tsx` fences to this repo's style. It leaves `svelte` fences alone, since `prettier-plugin-svelte` is not installed here, so format those by hand to match. + ### Getting Started To get the project running locally: diff --git a/packages/docs/astro.config.mjs b/packages/docs/astro.config.mjs index 03c75fd7..75e63e55 100644 --- a/packages/docs/astro.config.mjs +++ b/packages/docs/astro.config.mjs @@ -21,6 +21,7 @@ export default defineConfig({ { label: 'Dev Time', link: '/dev-time/' }, { label: 'Run Time', link: '/run-time/' }, { label: 'All Frameworks', link: '/all-frameworks/' }, + { label: 'Code Comparison', link: '/code-comparison/' }, { label: 'Methodology', link: '/methodology/' }, { label: 'Glossary', link: '/glossary/' }, ], diff --git a/packages/docs/src/components/CodeComparison.astro b/packages/docs/src/components/CodeComparison.astro new file mode 100644 index 00000000..e5b3318a --- /dev/null +++ b/packages/docs/src/components/CodeComparison.astro @@ -0,0 +1,243 @@ +--- +import { render } from 'astro:content' +import { getCodeComparison } from '../lib/collections' +import { frameworkLogos } from '../lib/framework-logos' + +type Props = { + example: string +} + +const { example } = Astro.props + +const frameworks = getCodeComparison(example) + +if (frameworks.every((framework) => framework.entry == null)) { + throw new Error( + `No code comparison snippets found for example "${example}". Expected files in src/content/code-comparison/${example}/.`, + ) +} + +const tabs = await Promise.all( + frameworks.map(async ({ name, slug, package: packageName, entry }) => ({ + name, + slug, + logo: frameworkLogos[packageName], + docs: entry?.data.docs, + Content: entry == null ? null : (await render(entry)).Content, + tabId: `${example}-tab-${slug}`, + panelId: `${example}-panel-${slug}`, + })), +) +--- + + +
+ { + tabs.map(({ name, slug, logo, tabId, panelId }, index) => ( + + )) + } +
+ { + tabs.map(({ name, docs, Content, tabId, panelId }, index) => ( + + )) + } +
+ + + + diff --git a/packages/docs/src/components/FrameworkLists.astro b/packages/docs/src/components/FrameworkLists.astro index 802c28b5..6ee42a5e 100644 --- a/packages/docs/src/components/FrameworkLists.astro +++ b/packages/docs/src/components/FrameworkLists.astro @@ -1,37 +1,22 @@ --- import { CardGrid, LinkCard } from '@astrojs/starlight/components' import { starterStats } from '../lib/collections' +import { frameworkLogos } from '../lib/framework-logos' import { getFrameworkSlug } from '../lib/utils' -const frameworkLogos: Record< - string, - { src?: string; symbol?: string; className?: string } -> = { - 'starter-astro': { - src: '/framework-logos/astro-gradient.svg', - className: 'framework-card-logo--astro', - }, - 'starter-mastro': { symbol: '👨‍🍳' }, - 'starter-next-js': { - src: '/framework-logos/nextdotjs-light.svg', - className: 'framework-card-logo--next', - }, - 'starter-nuxt': { src: '/framework-logos/nuxt.svg' }, - 'starter-react-router': { src: '/framework-logos/reactrouter.svg' }, - 'starter-solid-start': { src: '/framework-logos/solid-start.svg' }, - 'starter-sveltekit': { src: '/framework-logos/svelte.svg' }, - 'starter-tanstack-start-react': { - src: '/framework-logos/tanstack.svg', - }, -} - function getFrameworkTitle(name: string, packageName: string) { const logo = frameworkLogos[packageName] - const visual = - logo?.src != null - ? `` - : `` - + let visual: string + if (logo == null || 'symbol' in logo) { + visual = `` + } else { + const dark = `` + const light = + logo.light != null + ? `` + : '' + visual = dark + light + } return `${visual}${name}` } @@ -117,12 +102,4 @@ const additionalFrameworks = frameworkRows.filter( font-size: 1.25rem; line-height: 1; } - - :global(:root[data-theme='light'] .framework-card-logo--next) { - filter: invert(1); - } - - :global(:root[data-theme='light'] .framework-card-logo--astro) { - content: url('/framework-logos/astro-dark.svg'); - } diff --git a/packages/docs/src/content.config.ts b/packages/docs/src/content.config.ts index d9a02270..082dccb2 100644 --- a/packages/docs/src/content.config.ts +++ b/packages/docs/src/content.config.ts @@ -3,6 +3,7 @@ import { file, glob } from 'astro/loaders' import { docsLoader } from '@astrojs/starlight/loaders' import { docsSchema } from '@astrojs/starlight/schema' import { z } from 'astro/zod' +import { readdirSync } from 'node:fs' const timeSchema = z.object({ avgMs: z.number(), @@ -240,6 +241,31 @@ const cwvCollection = defineCollection({ }), }) +// Derived from the devtime filenames rather than hardcoded, so adding a +// framework does not silently break every code comparison example. +const frameworkSlugs = new Set( + readdirSync(new URL('./content/devtime', import.meta.url)) + .filter((file) => file.endsWith('.json')) + .map((file) => file.replace(/^starter-/, '').replace(/\.json$/, '')), +) + +const codeComparisonCollection = defineCollection({ + loader: glob({ + pattern: '**/*', + base: './src/content/code-comparison', + generateId: ({ entry }) => { + const slug = /^[^/]+\/([^/]+)\.md$/.exec(entry)?.[1] + if (slug == null || !frameworkSlugs.has(slug)) { + throw new Error( + `Invalid code comparison file "${entry}". Expected /.md where is one of: ${[...frameworkSlugs].join(', ')}.`, + ) + } + return entry.replace(/\.md$/, '') + }, + }), + schema: z.object({ docs: z.url().optional() }).strict(), +}) + const docsCollection: ReturnType = defineCollection({ loader: docsLoader(), schema: docsSchema(), @@ -252,4 +278,5 @@ export const collections = { runtime: runtimeCollection, runtimeVersions: runtimeVersionsCollection, cwv: cwvCollection, + codeComparison: codeComparisonCollection, } diff --git a/packages/docs/src/content/code-comparison/create-a-route/astro.md b/packages/docs/src/content/code-comparison/create-a-route/astro.md new file mode 100644 index 00000000..ea26f2c9 --- /dev/null +++ b/packages/docs/src/content/code-comparison/create-a-route/astro.md @@ -0,0 +1,21 @@ +--- +docs: https://docs.astro.build/en/guides/routing/ +--- + +The starter builds static output, so a dynamic route has to enumerate the pages to build. Adding `export const prerender = false` switches the route to per-request rendering instead, which then needs an adapter to build and deploy. + +```astro title="src/pages/about.astro" +

About

+``` + +```astro title="src/pages/posts/[id].astro" +--- +export function getStaticPaths() { + return [{ params: { id: '1' } }] +} + +const { id } = Astro.params +--- + +

Post {id}

+``` diff --git a/packages/docs/src/content/code-comparison/create-a-route/mastro.md b/packages/docs/src/content/code-comparison/create-a-route/mastro.md new file mode 100644 index 00000000..d336cb4b --- /dev/null +++ b/packages/docs/src/content/code-comparison/create-a-route/mastro.md @@ -0,0 +1,19 @@ +--- +docs: https://mastrojs.github.io/docs/routing/ +--- + +```ts title="routes/about.server.ts" +import { html, htmlToResponse } from '@mastrojs/mastro' + +export const GET = () => htmlToResponse(html`

About

`) +``` + +```ts title="routes/posts/[id].server.ts" +import { getParams, html, htmlToResponse } from '@mastrojs/mastro' + +export const GET = (request: Request) => { + const { id } = getParams(request) + + return htmlToResponse(html`

Post ${id}

`) +} +``` diff --git a/packages/docs/src/content/code-comparison/create-a-route/next-js.md b/packages/docs/src/content/code-comparison/create-a-route/next-js.md new file mode 100644 index 00000000..5aebab5e --- /dev/null +++ b/packages/docs/src/content/code-comparison/create-a-route/next-js.md @@ -0,0 +1,21 @@ +--- +docs: https://nextjs.org/docs/app/getting-started/layouts-and-pages +--- + +```tsx title="app/about/page.tsx" +export default function AboutPage() { + return

About

+} +``` + +```tsx title="app/posts/[id]/page.tsx" +interface Props { + params: Promise<{ id: string }> +} + +export default async function PostPage({ params }: Props) { + const { id } = await params + + return

Post {id}

+} +``` diff --git a/packages/docs/src/content/code-comparison/create-a-route/nuxt.md b/packages/docs/src/content/code-comparison/create-a-route/nuxt.md new file mode 100644 index 00000000..09e718c7 --- /dev/null +++ b/packages/docs/src/content/code-comparison/create-a-route/nuxt.md @@ -0,0 +1,27 @@ +--- +docs: https://nuxt.com/docs/getting-started/routing +--- + +The starter has no `pages/` directory and renders `app.vue` directly. Adding one turns the router on, so `app.vue` has to hand over to `` and `/` needs its own `app/pages/index.vue` from then on. + +```vue title="app/app.vue" + +``` + +```vue title="app/pages/about.vue" + +``` + +```vue title="app/pages/posts/[id].vue" + + + +``` diff --git a/packages/docs/src/content/code-comparison/create-a-route/react-router.md b/packages/docs/src/content/code-comparison/create-a-route/react-router.md new file mode 100644 index 00000000..18c95c62 --- /dev/null +++ b/packages/docs/src/content/code-comparison/create-a-route/react-router.md @@ -0,0 +1,29 @@ +--- +docs: https://reactrouter.com/start/framework/routing +--- + +Routes are registered in `app/routes.ts` rather than inferred from file names. The `./+types/*` modules are generated by `react-router typegen`, which the dev server runs on boot. + +```ts title="app/routes.ts" +import { type RouteConfig, index, route } from '@react-router/dev/routes' + +export default [ + index('routes/home.tsx'), + route('about', 'routes/about.tsx'), + route('posts/:id', 'routes/post.tsx'), +] satisfies RouteConfig +``` + +```tsx title="app/routes/about.tsx" +export default function About() { + return

About

+} +``` + +```tsx title="app/routes/post.tsx" +import type { Route } from './+types/post' + +export default function Post({ params }: Route.ComponentProps) { + return

Post {params.id}

+} +``` diff --git a/packages/docs/src/content/code-comparison/create-a-route/solid-start.md b/packages/docs/src/content/code-comparison/create-a-route/solid-start.md new file mode 100644 index 00000000..46e08b1f --- /dev/null +++ b/packages/docs/src/content/code-comparison/create-a-route/solid-start.md @@ -0,0 +1,19 @@ +--- +docs: https://docs.solidjs.com/solid-start/v2/building-your-application/routing +--- + +```tsx title="src/routes/about.tsx" +export default function About() { + return

About

+} +``` + +```tsx title="src/routes/posts/[id].tsx" +import { useParams } from '@solidjs/router' + +export default function Post() { + const params = useParams() + + return

Post {params.id}

+} +``` diff --git a/packages/docs/src/content/code-comparison/create-a-route/sveltekit.md b/packages/docs/src/content/code-comparison/create-a-route/sveltekit.md new file mode 100644 index 00000000..094a0cac --- /dev/null +++ b/packages/docs/src/content/code-comparison/create-a-route/sveltekit.md @@ -0,0 +1,17 @@ +--- +docs: https://svelte.dev/docs/kit/routing +--- + +```svelte title="src/routes/about/+page.svelte" +

About

+``` + +```svelte title="src/routes/posts/[id]/+page.svelte" + + +

Post {params.id}

+``` diff --git a/packages/docs/src/content/code-comparison/create-a-route/tanstack-start-react.md b/packages/docs/src/content/code-comparison/create-a-route/tanstack-start-react.md new file mode 100644 index 00000000..52426a2e --- /dev/null +++ b/packages/docs/src/content/code-comparison/create-a-route/tanstack-start-react.md @@ -0,0 +1,28 @@ +--- +docs: https://tanstack.com/start/latest/docs/framework/react/guide/routing +--- + +The exported route object has to be named `Route`. The dev server regenerates +`src/routeTree.gen.ts` from the files in `src/routes`. + +```tsx title="src/routes/about.tsx" +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/about')({ component: About }) + +function About() { + return

About

+} +``` + +```tsx title="src/routes/posts.$id.tsx" +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$id')({ component: Post }) + +function Post() { + const { id } = Route.useParams() + + return

Post {id}

+} +``` diff --git a/packages/docs/src/content/docs/code-comparison.mdx b/packages/docs/src/content/docs/code-comparison.mdx new file mode 100644 index 00000000..134cef1c --- /dev/null +++ b/packages/docs/src/content/docs/code-comparison.mdx @@ -0,0 +1,22 @@ +--- +title: Code Comparison +description: How each meta-framework solves the same everyday tasks. +--- + +import CodeComparison from '../../components/CodeComparison.astro' +import MethodologyLink from '../../components/MethodologyLink.astro' + +Each section below is one everyday task. The tabs show how each framework does +it, written against the starter projects this site tracks on +[Dev Time](/dev-time/). Every titled block is a complete file, so the task is +done by writing exactly the files shown into a fresh starter. Your framework +choice is remembered across examples and on your next visit. + + + +## Create a Route + +Add a static page at `/about` that renders an `About` heading, and a dynamic +page at `/posts/1` that reads the id from the URL and renders it. + + diff --git a/packages/docs/src/content/docs/methodology.md b/packages/docs/src/content/docs/methodology.md index d2dfed2e..ef993d00 100644 --- a/packages/docs/src/content/docs/methodology.md +++ b/packages/docs/src/content/docs/methodology.md @@ -319,3 +319,20 @@ or networks. - The docs publish framework-level desktop and mobile percentages from the latest collected HTTP Archive snapshot in the repository. - Metrics refresh monthly when new HTTP Archive data is collected. + +## Code Comparison + +The Code Comparison page shows how each framework solves one everyday task. It +is written by hand rather than generated, so it carries its own rules. + +- Snippets target the starter project versions published on Dev Time, not the + latest release of each framework. +- Each titled code block is a complete file. A task is done by writing exactly + the files shown into a fresh copy of that starter, with no other edits and no + added dependencies. A block whose title names a file the starter already ships + replaces it. +- Snippets are kept minimal on purpose. Layouts, styling, metadata, and error + handling are left out so the framework's own answer to the task is the only + thing on screen. +- Snippets are verified by writing them into a fresh copy of the starter, + starting its dev server with `pnpm dev`, and requesting the routes they add. diff --git a/packages/docs/src/lib/collections.ts b/packages/docs/src/lib/collections.ts index 4667b0fd..9b3be61a 100644 --- a/packages/docs/src/lib/collections.ts +++ b/packages/docs/src/lib/collections.ts @@ -1,11 +1,12 @@ import { getCollection } from 'astro:content' -import { formatBytesToMB, formatTimeMs } from './utils' +import { formatBytesToMB, formatTimeMs, getFrameworkSlug } from './utils' const devtimeEntries = await getCollection('devtime') const devtimeVersionEntries = await getCollection('devtimeVersions') const runtimeEntries = await getCollection('runtime') const runtimeVersionEntries = await getCollection('runtimeVersions') const cwvEntries = await getCollection('cwv') +const codeComparisonEntries = await getCollection('codeComparison') type DevtimeVersionData = (typeof devtimeVersionEntries)[number]['data'] type RuntimeVersionData = (typeof runtimeVersionEntries)[number]['data'] @@ -88,6 +89,20 @@ export const starterStats = devtimeEntries .map((entry) => entry.data) .sort((a, b) => a.order - b.order) +export function getCodeComparison(example: string) { + return starterStats.map((framework) => { + const slug = getFrameworkSlug(framework.package) + return { + name: framework.name, + package: framework.package, + slug, + entry: codeComparisonEntries.find( + (entry) => entry.id === `${example}/${slug}`, + ), + } + }) +} + export const ssrRequestThroughputStats = runtimeEntries .map((entry) => entry.data) .sort((a, b) => a.order - b.order) diff --git a/packages/docs/src/lib/framework-logos.ts b/packages/docs/src/lib/framework-logos.ts new file mode 100644 index 00000000..5f38370d --- /dev/null +++ b/packages/docs/src/lib/framework-logos.ts @@ -0,0 +1,20 @@ +export type FrameworkLogo = + { dark: string; light?: string } | { symbol: string } + +/** Keyed by devtime starter package name. */ +export const frameworkLogos: Record = { + 'starter-astro': { + dark: '/framework-logos/astro-gradient.svg', + light: '/framework-logos/astro-dark.svg', + }, + 'starter-mastro': { symbol: '👨‍🍳' }, + 'starter-next-js': { + dark: '/framework-logos/nextdotjs-light.svg', + light: '/framework-logos/nextdotjs.svg', + }, + 'starter-nuxt': { dark: '/framework-logos/nuxt.svg' }, + 'starter-react-router': { dark: '/framework-logos/reactrouter.svg' }, + 'starter-solid-start': { dark: '/framework-logos/solid-start.svg' }, + 'starter-sveltekit': { dark: '/framework-logos/svelte.svg' }, + 'starter-tanstack-start-react': { dark: '/framework-logos/tanstack.svg' }, +} diff --git a/packages/docs/src/styles/starlight.css b/packages/docs/src/styles/starlight.css index ef158b06..3025faa9 100644 --- a/packages/docs/src/styles/starlight.css +++ b/packages/docs/src/styles/starlight.css @@ -162,3 +162,24 @@ mobile-starlight-toc { .depot p:empty { display: none; } + +/* Logos with a second light-mode file ship both elements and swap here, + because Starlight toggles data-theme rather than prefers-color-scheme. The + margin reset undoes the 1rem Starlight gives any sibling inside + .sl-markdown-content, which would otherwise drop the second variant. */ +.framework-logo--dark, +.framework-logo--light { + margin-top: 0; +} + +.framework-logo--light { + display: none; +} + +:root[data-theme='light'] .framework-logo--dark { + display: none; +} + +:root[data-theme='light'] .framework-logo--light { + display: inline-block; +}