Skip to content
Open
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
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<example>/<framework>.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"
<h1>About</h1>
```
````

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-<framework>` 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 `<CodeComparison example="..." />` 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:
Expand Down
1 change: 1 addition & 0 deletions packages/docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
],
Expand Down
243 changes: 243 additions & 0 deletions packages/docs/src/components/CodeComparison.astro
Original file line number Diff line number Diff line change
@@ -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}`,
})),
)
---

<code-comparison>
<div role="tablist" aria-label="Framework">
{
tabs.map(({ name, slug, logo, tabId, panelId }, index) => (
<button
type="button"
role="tab"
id={tabId}
aria-controls={panelId}
aria-selected={index === 0 ? 'true' : 'false'}
tabindex={index === 0 ? 0 : -1}
data-framework={slug}
title={name}
class="code-comparison-tab"
>
{logo == null || 'symbol' in logo ? (
<span class="code-comparison-symbol" aria-hidden="true">
{logo?.symbol ?? '◼'}
</span>
) : (
<Fragment>
<img
class:list={[
'code-comparison-logo',
logo.light != null && 'framework-logo--dark',
]}
src={logo.dark}
alt=""
/>
{logo.light != null && (
<img
class="code-comparison-logo framework-logo--light"
src={logo.light}
alt=""
/>
)}
</Fragment>
)}
<span class="sr-only">{name}</span>
</button>
))
}
</div>
{
tabs.map(({ name, docs, Content, tabId, panelId }, index) => (
<div
role="tabpanel"
id={panelId}
aria-labelledby={tabId}
hidden={index !== 0}
>
{Content == null ? (
<p>
Not documented for {name} yet.{' '}
<a href="https://github.com/e18e/framework-tracker/issues/111">
Contribute this example
</a>
.
</p>
) : (
<Fragment>
<Content />
{docs != null && (
<p>
<a href={docs}>{name} documentation</a>
</p>
)}
</Fragment>
)}
</div>
))
}
</code-comparison>

<style>
code-comparison {
display: block;
}

[role='tablist'] {
display: flex;
flex-wrap: wrap;
padding: 0;
border-bottom: 2px solid var(--ft-border);
}

/* Starlight gives every sibling inside .sl-markdown-content a 1rem top margin,
which would drop every tab but the first out of the strip. */
.code-comparison-tab {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0;
margin-bottom: -2px;
padding: 0.5rem 1rem;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--ft-muted);
cursor: pointer;
opacity: 0.75;
}

.code-comparison-tab:hover,
.code-comparison-tab:focus-visible {
opacity: 1;
}

.code-comparison-tab[aria-selected='true'] {
border-bottom-color: var(--ft-accent);
color: var(--ft-text);
opacity: 1;
}

.code-comparison-logo,
.code-comparison-symbol {
width: 1.5rem;
height: 1.5rem;
flex: none;
}

.code-comparison-logo {
object-fit: contain;
}

.code-comparison-symbol {
display: inline-grid;
place-items: center;
font-size: 1.25rem;
line-height: 1;
}

[role='tabpanel'] {
margin-top: 1rem;
}
</style>

<script>
const STORAGE_KEY = 'code-comparison-framework'

function readStored() {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}

function selectEverywhere(slug: string | undefined) {
if (slug == null) return
try {
localStorage.setItem(STORAGE_KEY, slug)
} catch {
// Private browsing refuses writes; the selection still applies to this page view.
}
for (const element of document.querySelectorAll<CodeComparison>(
'code-comparison',
)) {
element.select(slug)
}
}

class CodeComparison extends HTMLElement {
tabs: HTMLButtonElement[] = []

connectedCallback() {
this.tabs = [...this.querySelectorAll<HTMLButtonElement>('[role="tab"]')]
this.addEventListener('click', (event) => {
const tab = (event.target as HTMLElement).closest<HTMLButtonElement>(
'[role="tab"]',
)
selectEverywhere(tab?.dataset.framework)
})
this.addEventListener('keydown', (event) => {
const index = this.tabs.indexOf(
document.activeElement as HTMLButtonElement,
)
if (index < 0) return
const last = this.tabs.length - 1
const next: Record<string, number> = {
ArrowLeft: index === 0 ? last : index - 1,
ArrowRight: index === last ? 0 : index + 1,
Home: 0,
End: last,
}
const targetIndex = next[event.key]
const target = targetIndex == null ? undefined : this.tabs[targetIndex]
if (target == null) return
event.preventDefault()
selectEverywhere(target.dataset.framework)
target.focus()
})
this.select(readStored() ?? this.tabs[0]?.dataset.framework ?? '')
}

select(slug: string) {
if (!this.tabs.some((tab) => tab.dataset.framework === slug)) return
for (const tab of this.tabs) {
const selected = tab.dataset.framework === slug
tab.setAttribute('aria-selected', String(selected))
tab.tabIndex = selected ? 0 : -1
const panel = document.getElementById(
tab.getAttribute('aria-controls') ?? '',
)
if (panel != null) panel.hidden = !selected
}
}
}

customElements.define('code-comparison', CodeComparison)
</script>
47 changes: 12 additions & 35 deletions packages/docs/src/components/FrameworkLists.astro
Original file line number Diff line number Diff line change
@@ -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
? `<img class="framework-card-logo ${logo.className ?? ''}" src="${logo.src}" alt="" />`
: `<span class="framework-card-symbol" aria-hidden="true">${logo?.symbol ?? '◼'}</span>`

let visual: string
if (logo == null || 'symbol' in logo) {
visual = `<span class="framework-card-symbol" aria-hidden="true">${logo?.symbol ?? '◼'}</span>`
} else {
const dark = `<img class="framework-card-logo${logo.light != null ? ' framework-logo--dark' : ''}" src="${logo.dark}" alt="" />`
const light =
logo.light != null
? `<img class="framework-card-logo framework-logo--light" src="${logo.light}" alt="" />`
: ''
visual = dark + light
}
return `<span class="framework-card-title">${visual}<span>${name}</span></span>`
}

Expand Down Expand Up @@ -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');
}
</style>
27 changes: 27 additions & 0 deletions packages/docs/src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 <example>/<framework>.md where <framework> is one of: ${[...frameworkSlugs].join(', ')}.`,
)
}
return entry.replace(/\.md$/, '')
},
}),
schema: z.object({ docs: z.url().optional() }).strict(),
})

const docsCollection: ReturnType<typeof defineCollection> = defineCollection({
loader: docsLoader(),
schema: docsSchema(),
Expand All @@ -252,4 +278,5 @@ export const collections = {
runtime: runtimeCollection,
runtimeVersions: runtimeVersionsCollection,
cwv: cwvCollection,
codeComparison: codeComparisonCollection,
}
Loading
Loading