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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ package-lock.json
/playwright-report/
/playwright/.cache/
/css-coverage
/src/lib/data/css-features.generated.json
/src/lib/data/compat-keys.generated.json

# Sentry Config File
.env.sentry-build-plugin
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
"#lib/*": "./src/lib/*"
},
"scripts": {
"prepare": "pnpm generate:css-features",
"dev": "vite dev",
"build": "vite build",
"generate:css-features": "node scripts/generate-css-features.ts",
"preview": "vite preview",
"test": "svelte-kit sync && playwright test",
"test:unit": "vitest --exclude **/*.spec.ts",
Expand Down Expand Up @@ -48,7 +50,8 @@
"runed": "^0.37.1",
"svelte": "^5.56.8",
"typescript": "^6.0.3",
"vite": "catalog:"
"vite": "catalog:",
"web-features": "^3.34.3"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions scripts/generate-css-features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import data from 'web-features/data.json' with { type: 'json' }
import type { Baseline, CssFeature } from '../src/lib/data/css-feature.js'

type WebFeaturesData = {
features: typeof import('web-features').features
groups: Record<string, { name: string; parent?: string }>
}

const { features, groups } = data as WebFeaturesData

// The "css" group is just the root of a tree (css > selectors, css >
// container-queries, etc). A feature belongs to CSS if its group is "css"
// or descends from it, so walk the parent chain to collect every group id
// under that root.
const css_group_ids = new Set(['css'])
for (let added = true; added; ) {
added = false
for (const [id, group] of Object.entries(groups)) {
if (group.parent && css_group_ids.has(group.parent) && !css_group_ids.has(id)) {
css_group_ids.add(id)
added = true
}
}
}

const css_features: Record<string, CssFeature> = {}
const compat_keys: Record<string, string> = {}

for (const [id, feature] of Object.entries(features)) {
if (feature.kind !== 'feature' || !feature.group) {
continue
}

let feature_groups = Array.isArray(feature.group) ? feature.group : [feature.group]
if (!feature_groups.some((group) => css_group_ids.has(group))) {
continue
}

let baseline = feature.status.baseline as Baseline

css_features[id] = {
name: feature.name,
baseline,
baseline_low_date: feature.status.baseline_low_date,
baseline_high_date: feature.status.baseline_high_date,
// Only features with limited availability need this - for
// newly/widely-available ones, the baseline dates already say enough.
support: baseline === false ? Object.keys(feature.status.support ?? {}) : undefined
}

// Only `css.*` compat keys can be matched against a parsed stylesheet,
// so `api.*`/`html.*`/`svg.*` entries are dropped here.
for (const compat_feature of feature.compat_features ?? []) {
if (compat_feature.startsWith('css.')) {
compat_keys[compat_feature] = id
}
}
}

function write(relative_path: string, contents: unknown) {
const out = fileURLToPath(new URL(relative_path, import.meta.url))
writeFileSync(out, JSON.stringify(contents, undefined, 2))
return out
}

const features_path = write('../src/lib/data/css-features.generated.json', css_features)
const compat_keys_path = write('../src/lib/data/compat-keys.generated.json', compat_keys)

console.log(`Wrote ${Object.keys(css_features).length} CSS features to ${features_path}`)
console.log(`Wrote ${Object.keys(compat_keys).length} compat keys to ${compat_keys_path}`)
13 changes: 13 additions & 0 deletions src/lib/data/browsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* The browsers Baseline tracks support across, keyed by web-features browser
* id. Small and stable enough to hand-write rather than generate.
*/
export const browsers: Record<string, string> = {
chrome: 'Chrome',
chrome_android: 'Chrome Android',
edge: 'Edge',
firefox: 'Firefox',
firefox_android: 'Firefox Android',
safari: 'Safari',
safari_ios: 'Safari iOS'
}
14 changes: 14 additions & 0 deletions src/lib/data/css-feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type Baseline = false | 'low' | 'high'

export type CssFeature = {
name: string
baseline: Baseline
baseline_low_date?: string
baseline_high_date?: string
/**
* Browser ids (see `#lib/data/browsers.js`) that have added support, for
* features with limited availability. Only present when `baseline` is
* `false` - tracked/newly/widely-available features don't need it.
*/
support?: string[]
}
192 changes: 192 additions & 0 deletions src/routes/(public)/baseline-overview/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<script lang="ts">
import Seo from '#lib/components/Seo.svelte'
import Container from '#lib/components/Container.svelte'
import Form from '#lib/components/css-form/Form.svelte'
import Markdown from '#lib/components/Markdown.svelte'
import Hero from '#lib/components/Hero.svelte'
import Table from '#lib/components/Table.svelte'
import BarChart from '#lib/components/BarChart.svelte'
import FilterGroup from '#lib/components/FilterGroup.svelte'
import FilterOption from '#lib/components/FilterOption.svelte'
import { get_css_state } from '#lib/css-state.svelte.js'
import { analyze } from './calculate.js'
import { group_by_year, EDGE_LAUNCH_DATE } from './group-by-year.js'
import { summarize_usages } from './summarize-usages.js'
import css_features from '#lib/data/css-features.generated.json'
import type { CssFeature } from '#lib/data/css-feature.js'
import { browsers } from '#lib/data/browsers.js'
import Heading from '#lib/components/Heading.svelte'

const browser_count = Object.keys(browsers).length

function format_support(support: string[] | undefined) {
if (!support) return undefined
let names = support.map((id) => browsers[id] ?? id).join(', ')
return `${support.length}/${browser_count}: ${names}`
}

let css_state = get_css_state()
let usages = $derived(css_state.css.length > 0 ? analyze(css_state.css) : new Map())

function compare_dates(a: string | undefined, b: string | undefined) {
if (!a && !b) return 0
if (!a) return 1
if (!b) return -1
return a.localeCompare(b)
}

type FeatureRow = {
name: string
count: number
widely_available_since: string | undefined
newly_available_since: string | undefined
support: string | undefined
}

let sortings = [
{ id: 'feature', label: 'Sort by feature', fn: (a: FeatureRow, b: FeatureRow) => a.name.localeCompare(b.name) },
{ id: 'count', label: 'Sort by count', fn: (a: FeatureRow, b: FeatureRow) => b.count - a.count },
{
id: 'widely-available-since',
label: 'Sort by widely available since',
fn: (a: FeatureRow, b: FeatureRow) => compare_dates(a.widely_available_since, b.widely_available_since)
},
{
id: 'newly-available-since',
label: 'Sort by newly available since',
fn: (a: FeatureRow, b: FeatureRow) => compare_dates(a.newly_available_since, b.newly_available_since)
}
]

let sorting = $state(sortings[1].id)

let feature_rows = $derived.by(() => {
let rows: FeatureRow[] = []

for (let [feature_id, locations] of usages) {
let feature = (css_features as Record<string, CssFeature>)[feature_id]
// Edge's own launch stands in for a real support date on CSS old
// enough to predate it - not a real "since" date, so drop the row.
if (feature?.baseline_low_date === EDGE_LAUNCH_DATE) continue

rows.push({
name: feature?.name ?? feature_id,
count: locations.length,
widely_available_since: feature?.baseline === 'high' ? feature.baseline_high_date : undefined,
newly_available_since: feature?.baseline_low_date,
support: format_support(feature?.support)
})
}

let sort = sortings.find((s) => s.id === sorting) ?? sortings[1]
return rows.sort(sort.fn)
})

// BarChart takes a plain object, and JS always sorts numeric-looking keys
// ("2018") ahead of non-numeric ones ("≤2017") regardless of insertion
// order - so the ≤2017 bucket renders as the last bar, not the first.
let widely_available_by_year = $derived(Object.fromEntries(group_by_year(usages)))

let usage_summary = $derived(summarize_usages(usages))
let summary_rows = $derived([
{ label: 'Widely available', counts: usage_summary.widely_available },
{ label: 'Newly available', counts: usage_summary.newly_available },
{ label: 'Limited availability', counts: usage_summary.limited_availability }
])
let summary_chart_data = $derived(Object.fromEntries(summary_rows.map((row) => [row.label, row.counts.features])))
</script>

<Seo
title="CSS Baseline overview"
description="See the composition of your CSS with regards to different Baseline features."
/>

<Hero>
<Form>
{#snippet title()}
<h1 class="font-heading">Baseline overview</h1>
{/snippet}
</Form>
</Hero>

<Container>
<Heading element="h2">Baseline status summary</Heading>
<BarChart
data={summary_chart_data}
title="Baseline status summary"
alt="Number of distinct CSS features used, grouped by Baseline status: widely available, newly available, or limited availability"
/>
<Table>
<thead>
<tr>
<th scope="col">Status</th>
<th scope="col" class="numeric">Features</th>
<th scope="col" class="numeric">Usages</th>
</tr>
</thead>
<tbody>
{#each summary_rows as row (row.label)}
<tr>
<td>{row.label}</td>
<td class="numeric">{row.counts.features}</td>
<td class="numeric">{row.counts.usages}</td>
</tr>
{/each}
</tbody>
</Table>

<Heading element="h2">Widely available features by year</Heading>
<BarChart
data={widely_available_by_year}
title="Widely available features by year"
alt="Number of distinct Baseline-widely-available CSS features used, grouped by the year they became widely available. Features whose exact year predates Microsoft Edge's 2015 launch are excluded, since Baseline only has an artifact date for them, not a real one."
/>

<Heading element="h2">Feature usage</Heading>
<FilterGroup>
<legend class="sr-only">Sorting</legend>
{#each sortings as sort (sort.id)}
<FilterOption bind:group={sorting} value={sort.id} id="sort-{sort.id}" name="feature-usage-sorting">
{sort.label}
</FilterOption>
{/each}
</FilterGroup>
<Table>
<thead>
<tr>
<th scope="col" aria-sort={sorting === 'feature' ? 'ascending' : undefined}>Feature</th>
<th scope="col" class="numeric" aria-sort={sorting === 'count' ? 'descending' : undefined}>Count</th>
<th scope="col" aria-sort={sorting === 'widely-available-since' ? 'ascending' : undefined}>
Widely available since
</th>
<th scope="col" aria-sort={sorting === 'newly-available-since' ? 'ascending' : undefined}>
Newly available since
</th>
<th scope="col">Browser support</th>
</tr>
</thead>
<tbody>
{#each feature_rows as row (row.name)}
<tr>
<td>{row.name}</td>
<td class="numeric">{row.count}</td>
<td>{row.widely_available_since ?? '—'}</td>
<td>{row.newly_available_since ?? '—'}</td>
<td>{row.support ?? '—'}</td>
</tr>
{/each}
</tbody>
</Table>
</Container>

<Container size="lg">
<Markdown class="my-16">
<h2>TODO: Content here</h2>
</Markdown>
</Container>

<style>
.font-heading {
font-size: var(--size-5xl);
}
</style>
41 changes: 41 additions & 0 deletions src/routes/(public)/baseline-overview/calculate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { parse, walk, type CSSNode } from '@projectwallace/css-parser'
import type { CssLocation } from '#lib/css-location.js'
import compat_keys from '#lib/data/compat-keys.generated.json'
import { match_node } from './match-node.js'

function to_loc(node: CSSNode): CssLocation {
return {
line: node.line,
column: node.column,
offset: node.start,
length: node.length
}
}

/**
* Every occurrence of a Baseline-tracked feature in the CSS, keyed by
* web-features id. `locations.length` is the usage count; the locations
* themselves are kept so a DevTools-style panel can jump to each occurrence.
*/
export function analyze(css: string): Map<string, CssLocation[]> {
let ast = parse(css, {
parse_atrule_preludes: false,
parse_selectors: true,
parse_values: true
})
let usages = new Map<string, CssLocation[]>()

walk(ast, (node) => {
for (let compat_key of match_node(node)) {
let feature_id = (compat_keys as Record<string, string>)[compat_key]
if (!feature_id) continue

let loc = to_loc(node)
let locations = usages.get(feature_id)
if (locations) locations.push(loc)
else usages.set(feature_id, [loc])
}
})

return usages
}
Loading
Loading