From ba5d387d9dd3de6e7fa046c82d91def0a45430f4 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 26 Aug 2026 20:31:22 +0200 Subject: [PATCH] WIP: add feature baselines page --- .gitignore | 2 + package.json | 5 +- pnpm-lock.yaml | 8 + scripts/generate-css-features.ts | 72 +++++++ src/lib/data/browsers.ts | 13 ++ src/lib/data/css-feature.ts | 14 ++ .../(public)/baseline-overview/+page.svelte | 192 ++++++++++++++++++ .../(public)/baseline-overview/calculate.ts | 41 ++++ .../baseline-overview/group-by-year.ts | 41 ++++ .../(public)/baseline-overview/match-node.ts | 43 ++++ .../baseline-overview/summarize-usages.ts | 44 ++++ tsconfig.json | 2 +- 12 files changed, 475 insertions(+), 2 deletions(-) create mode 100644 scripts/generate-css-features.ts create mode 100644 src/lib/data/browsers.ts create mode 100644 src/lib/data/css-feature.ts create mode 100644 src/routes/(public)/baseline-overview/+page.svelte create mode 100644 src/routes/(public)/baseline-overview/calculate.ts create mode 100644 src/routes/(public)/baseline-overview/group-by-year.ts create mode 100644 src/routes/(public)/baseline-overview/match-node.ts create mode 100644 src/routes/(public)/baseline-overview/summarize-usages.ts diff --git a/.gitignore b/.gitignore index 569b0b8..c055103 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/package.json b/package.json index 9cf8bb5..5b37a92 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 248a8df..d3551d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: vite: specifier: 8.2.1 version: 8.2.1(@types/node@25.9.5)(esbuild@0.28.1) + web-features: + specifier: ^3.34.3 + version: 3.34.3 devDependencies: '@playwright/test': specifier: ^1.62.0 @@ -2196,6 +2199,9 @@ packages: jsdom: optional: true + web-features@3.34.3: + resolution: {integrity: sha512-eXdaGO8JSiLLC5/tLOeDiH0EVIbuD8NExMDAziU0frr9JRdiEKqyOsTeEZDtmCXbazEI3mB+plYO4oMYOj1/Dg==} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -3943,6 +3949,8 @@ snapshots: transitivePeerDependencies: - msw + web-features@3.34.3: {} + which@1.3.1: dependencies: isexe: 2.0.0 diff --git a/scripts/generate-css-features.ts b/scripts/generate-css-features.ts new file mode 100644 index 0000000..d97a5b3 --- /dev/null +++ b/scripts/generate-css-features.ts @@ -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 +} + +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 = {} +const compat_keys: Record = {} + +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}`) diff --git a/src/lib/data/browsers.ts b/src/lib/data/browsers.ts new file mode 100644 index 0000000..aaf0c18 --- /dev/null +++ b/src/lib/data/browsers.ts @@ -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 = { + chrome: 'Chrome', + chrome_android: 'Chrome Android', + edge: 'Edge', + firefox: 'Firefox', + firefox_android: 'Firefox Android', + safari: 'Safari', + safari_ios: 'Safari iOS' +} diff --git a/src/lib/data/css-feature.ts b/src/lib/data/css-feature.ts new file mode 100644 index 0000000..806431a --- /dev/null +++ b/src/lib/data/css-feature.ts @@ -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[] +} diff --git a/src/routes/(public)/baseline-overview/+page.svelte b/src/routes/(public)/baseline-overview/+page.svelte new file mode 100644 index 0000000..df761f5 --- /dev/null +++ b/src/routes/(public)/baseline-overview/+page.svelte @@ -0,0 +1,192 @@ + + + + + +
+ {#snippet title()} +

Baseline overview

+ {/snippet} +
+
+ + + Baseline status summary + + + + + + + + + + + {#each summary_rows as row (row.label)} + + + + + + {/each} + +
StatusFeaturesUsages
{row.label}{row.counts.features}{row.counts.usages}
+ + Widely available features by year + + + Feature usage + + Sorting + {#each sortings as sort (sort.id)} + + {sort.label} + + {/each} + + + + + + + + + + + + + {#each feature_rows as row (row.name)} + + + + + + + + {/each} + +
FeatureCount + Widely available since + + Newly available since + Browser support
{row.name}{row.count}{row.widely_available_since ?? '—'}{row.newly_available_since ?? '—'}{row.support ?? '—'}
+
+ + + +

TODO: Content here

+
+
+ + diff --git a/src/routes/(public)/baseline-overview/calculate.ts b/src/routes/(public)/baseline-overview/calculate.ts new file mode 100644 index 0000000..de78e79 --- /dev/null +++ b/src/routes/(public)/baseline-overview/calculate.ts @@ -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 { + let ast = parse(css, { + parse_atrule_preludes: false, + parse_selectors: true, + parse_values: true + }) + let usages = new Map() + + walk(ast, (node) => { + for (let compat_key of match_node(node)) { + let feature_id = (compat_keys as Record)[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 +} diff --git a/src/routes/(public)/baseline-overview/group-by-year.ts b/src/routes/(public)/baseline-overview/group-by-year.ts new file mode 100644 index 0000000..8be9fd0 --- /dev/null +++ b/src/routes/(public)/baseline-overview/group-by-year.ts @@ -0,0 +1,41 @@ +import css_features from '#lib/data/css-features.generated.json' +import type { CssFeature } from '#lib/data/css-feature.js' + +/** + * Baseline's "newly available" date is when the last of the 5 tracked + * browsers gained support. For CSS that predates Microsoft Edge, that + * browser didn't exist yet - so Edge's own release date gets used as a + * stand-in "last browser" date instead of a real support date. That's not + * when the feature actually became available, just an artifact of Edge's + * launch, so features carrying this date are excluded wherever exact years + * matter (the chart here, and the usage table's date columns). + */ +export const EDGE_LAUNCH_DATE = '2015-07-29' + +/** + * Counts distinct widely-available features per year, using the year they + * became widely available (`baseline_high_date`). Features that aren't + * widely available yet, that have no Baseline status, or whose date is just + * the Edge-launch artifact (see above) are dropped. Returns a Map (not a + * plain object) so callers control bar order - object keys that look like + * numbers get sorted before non-numeric ones regardless of insertion order. + */ +export function group_by_year(usages: Map): Map { + let year_counts = new Map() + + for (let feature_id of usages.keys()) { + let feature = (css_features as Record)[feature_id] + if (!feature || feature.baseline !== 'high' || !feature.baseline_high_date) continue + if (feature.baseline_low_date === EDGE_LAUNCH_DATE) continue + + let year = new Date(feature.baseline_high_date).getFullYear() + year_counts.set(year, (year_counts.get(year) ?? 0) + 1) + } + + let by_year = new Map() + for (let [year, count] of Array.from(year_counts).sort(([a], [b]) => a - b)) { + by_year.set(String(year), count) + } + + return by_year +} diff --git a/src/routes/(public)/baseline-overview/match-node.ts b/src/routes/(public)/baseline-overview/match-node.ts new file mode 100644 index 0000000..c37d64e --- /dev/null +++ b/src/routes/(public)/baseline-overview/match-node.ts @@ -0,0 +1,43 @@ +import { + type AnyNode, + is_atrule, + is_declaration, + is_function, + is_identifier, + is_pseudo_class_selector, + is_pseudo_element_selector, + is_value +} from '@projectwallace/css-parser' + +/** + * Maps a single AST node to the web-features `compat_features` key(s) it could + * represent, e.g. a `gap` declaration maps to `css.properties.gap`. Deeper + * compat keys (e.g. function-argument shapes like `css.types.attr.type_function.angle`) + * aren't matched here - this only covers the top-level properties, at-rules, + * selectors and functions that can be read directly off a node. + */ +export function match_node(node: AnyNode): string[] { + if (is_declaration(node)) { + let property = node.property.toLowerCase() + let keys = [`css.properties.${property}`] + let value = node.value + if (value && is_value(value) && value.first_child && is_identifier(value.first_child)) { + keys.push(`css.properties.${property}.${value.first_child.name.toLowerCase()}`) + } + return keys + } + + if (is_atrule(node)) { + return [`css.at-rules.${node.name.toLowerCase()}`] + } + + if (is_pseudo_class_selector(node) || is_pseudo_element_selector(node)) { + return [`css.selectors.${node.name.toLowerCase()}`] + } + + if (is_function(node)) { + return [`css.types.${node.name.toLowerCase()}`] + } + + return [] +} diff --git a/src/routes/(public)/baseline-overview/summarize-usages.ts b/src/routes/(public)/baseline-overview/summarize-usages.ts new file mode 100644 index 0000000..8aea461 --- /dev/null +++ b/src/routes/(public)/baseline-overview/summarize-usages.ts @@ -0,0 +1,44 @@ +import css_features from '#lib/data/css-features.generated.json' +import type { CssFeature } from '#lib/data/css-feature.js' +import type { CssLocation } from '#lib/css-location.js' + +export type UsageCounts = { + features: number + usages: number +} + +export type UsageSummary = { + widely_available: UsageCounts + newly_available: UsageCounts + limited_availability: UsageCounts +} + +/** + * Buckets Baseline-tracked usages by status, for a top-level "how healthy is + * this stylesheet" table. `features`/`usages` in each bucket count distinct + * features vs. total occurrences, respectively. + */ +export function summarize_usages(usages: Map): UsageSummary { + let summary: UsageSummary = { + widely_available: { features: 0, usages: 0 }, + newly_available: { features: 0, usages: 0 }, + limited_availability: { features: 0, usages: 0 } + } + + for (let [feature_id, locations] of usages) { + let feature = (css_features as Record)[feature_id] + if (!feature) continue + + let bucket = + feature.baseline === 'high' + ? summary.widely_available + : feature.baseline === 'low' + ? summary.newly_available + : summary.limited_availability + + bucket.features++ + bucket.usages += locations.length + } + + return summary +} diff --git a/tsconfig.json b/tsconfig.json index 802228d..4e01d5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,5 @@ "target": "ES2022", "useDefineForClassFields": true }, - "include": ["src"] + "include": ["src", "scripts"] }