Skip to content

Commit

Permalink
generate css compat table from data sources
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jul 13, 2023
1 parent c7cfcd7 commit 95feb2e
Show file tree
Hide file tree
Showing 8 changed files with 524 additions and 51 deletions.
111 changes: 99 additions & 12 deletions compat-table/src/caniuse.ts
@@ -1,7 +1,7 @@
// This file processes data from https://caniuse.com

import lite = require('caniuse-lite')
import { Engine, JSFeature, Support, SupportMap } from './index'
import { CSSFeature, CSSPrefixMap, CSSProperty, Engine, JSFeature, PrefixData, Support, SupportMap } from './index'

const enum StatusCode {
Almost = 'a',
Expand All @@ -27,33 +27,120 @@ const jsFeatures: Record<string, JSFeature> = {
'es6-module-dynamic-import': 'DynamicImport',
}

const cssFeatures: Record<string, CSSFeature> = {
'css-matches-pseudo': 'IsPseudoClass',
'css-nesting': 'Nesting',
}

const cssPrefixFeatures: Record<string, CSSProperty> = {
'css-appearance': 'DAppearance',
'css-backdrop-filter': 'DBackdropFilter',
'background-clip-text': 'DBackgroundClip',
'css-boxdecorationbreak': 'DBoxDecorationBreak',
'css-clip-path': 'DClipPath',
'font-kerning': 'DFontKerning',
'css-hyphens': 'DHyphens',
'css-initial-letter': 'DInitialLetter',
'css-sticky': 'DPosition',
'css-color-adjust': 'DPrintColorAdjust',
'css3-tabsize': 'DTabSize',
'css-text-orientation': 'DTextOrientation',
'text-size-adjust': 'DTextSizeAdjust',
}

export const js: SupportMap<JSFeature> = {} as SupportMap<JSFeature>
export const css: SupportMap<CSSFeature> = {} as SupportMap<CSSFeature>
export const cssPrefix: CSSPrefixMap = {}

const compareVersions = (aStr: string, bStr: string): number => {
const a = aStr.split('.')
const b = bStr.split('.')
let diff = +a[0] - +b[0]
if (diff === 0) {
diff = +(a[1] || '0') - +(b[1] || '0')
if (diff === 0) {
diff = +(a[2] || '0') - +(b[2] || '0')
}
}
return diff
}

const addFeatures = <F extends string>(map: SupportMap<F>, features: Record<string, F>): void => {
for (const feature in features) {
const engines: Partial<Record<Engine, Record<string, Support>>> = {}
const entry = lite.feature(lite.features[feature])

for (const agent in entry.stats) {
const engine = supportedAgents[agent]
if (!engine) continue

const versionRanges = entry.stats[agent]
const versions: Record<string, Support> = {}

for (const versionRange in versionRanges) {
const statusCodes = versionRanges[versionRange].split(' ')
const isSupported = statusCodes.includes(StatusCode.Yes)

for (const version of versionRange.split('-')) {
if (/^\d+(?:\.\d+(?:\.\d+)?)?$/.test(version)) {
versions[version] = { force: isSupported }
}
}
}

for (const feature in jsFeatures) {
const jsFeature = jsFeatures[feature]
const engines: Partial<Record<Engine, Record<string, Support>>> = {}
engines[engine] = versions
}

map[features[feature]] = engines
}
}

addFeatures(js, jsFeatures)
addFeatures(css, cssFeatures)

for (const feature in cssPrefixFeatures) {
const prefixData: PrefixData[] = []
const entry = lite.feature(lite.features[feature])

for (const agent in entry.stats) {
const engine = supportedAgents[agent]
if (!engine) continue

const model = lite.agents[agent]!
const versionRanges = entry.stats[agent]
const versions: Record<string, Support> = {}
const sortedVersions: { version: string, prefix: string | null }[] = []
const prefixes = new Set<string>()

for (const versionRange in versionRanges) {
const statusCodes = versionRanges[versionRange].split(' ')
const isSupported = statusCodes.includes(StatusCode.Yes)

const prefix = statusCodes.includes(StatusCode.Prefix)
? (model.prefix_exceptions && model.prefix_exceptions[versionRange]) || model.prefix
: null
for (const version of versionRange.split('-')) {
if (/^\d+(?:\.\d+(?:\.\d+)?)?$/.test(version)) {
versions[version] = { force: isSupported }
}
sortedVersions.push({ version, prefix })
}
if (prefix !== null) {
prefixes.add(prefix)
}
}

engines[engine] = versions
sortedVersions.sort((a, b) => compareVersions(a.version, b.version))

for (const prefix of prefixes) {
// Find the version after the latest version that requires the prefix (if there even is one)
let i = sortedVersions.length
while (i > 0 && sortedVersions[i - 1].prefix !== prefix) {
i--
}

// Add an entry for this prefix combination
const result: PrefixData = { engine, prefix }
if (i < sortedVersions.length) {
result.withoutPrefix = sortedVersions[i].version.split('.').map(x => +x)
}
prefixData.push(result)
}
}

js[jsFeature] = engines
cssPrefix[cssPrefixFeatures[feature]] = prefixData
}
158 changes: 158 additions & 0 deletions compat-table/src/css_table.ts
@@ -0,0 +1,158 @@
// This file generates "internal/compat/css_table.go"

import fs = require('fs')
import { Engine, CSSFeature, VersionRange, VersionRangeMap, CSSPrefixMap, PrefixData, CSSProperty } from './index'

const cssFeatureString = (feature: string): string => {
return feature.replace(/([A-Z]+)/g, '-$1').slice(1).toLowerCase().replace(/[-_]+/g, '-')
}

const simpleMap = (entries: [string, string][]) => {
let maxLength = 0
for (const [key] of entries) {
maxLength = Math.max(maxLength, key.length + 1)
}
return entries.map(([key, value]) => `\t${(key + ':').padEnd(maxLength)} ${value},`).join('\n')
}

const compareEngines = (a: Engine, b: Engine): number => {
const lowerA = a.toLowerCase()
const lowerB = b.toLowerCase()
return lowerA < lowerB ? -1 : lowerA > lowerB ? 1 : 0
}

const cssTableMap = (map: Partial<Record<Engine, VersionRange[]>>) => {
const engineKeys = (Object.keys(map) as Engine[]).sort(compareEngines)
const maxLength = engineKeys.reduce((a, b) => Math.max(a, b.length + 1), 0)
if (engineKeys.length === 0) return '{}'
return `{\n${engineKeys.map(engine => {
const items = map[engine]!.map(range => {
return `{start: v{${range.start.concat(0, 0).slice(0, 3).join(', ')
}}${range.end ? `, end: v{${range.end.concat(0, 0).slice(0, 3).join(', ')}}` : ''}}`
})
return `\t\t${(engine + ':').padEnd(maxLength)} {${items.join(', ')}},`
}).join('\n')}\n\t}`
}

const cssPrefixName = (prefix: string): string => {
return prefix[0].toUpperCase() + prefix.slice(1) + 'Prefix'
}

const cssPrefixMap = (entries: PrefixData[]) => {
if (entries.length === 0) return '{}'
entries.sort((a, b) => compareEngines(a.engine, b.engine))
return `{\n${entries.map(({ engine, prefix, withoutPrefix }) => {
const version = withoutPrefix && withoutPrefix.concat(0, 0).slice(0, 3).join(', ')
return `\t\t{engine: ${engine}, prefix: ${cssPrefixName(prefix)}${version ? `, withoutPrefix: v{${version}}` : ''}},`
}).join('\n')}\n\t}`
}

const generatedByComment = `// This file was automatically generated by "css_table.ts"`

export const generateTableForCSS = (map: VersionRangeMap<CSSFeature>, prefixes: CSSPrefixMap): void => {
const prefixNames = new Set<string>()
for (const property in prefixes) {
for (const { prefix } of prefixes[property as CSSProperty]!) {
prefixNames.add(cssPrefixName(prefix))
}
}

fs.writeFileSync(__dirname + '/../internal/compat/css_table.go',
`${generatedByComment}
package compat
import (
\t"github.com/evanw/esbuild/internal/css_ast"
)
type CSSFeature uint8
const (
${Object.keys(map).sort().map((feature, i) => `\t${feature}${i ? '' : ' CSSFeature = 1 << iota'}`).join('\n')}
)
var StringToCSSFeature = map[string]CSSFeature{
${simpleMap(Object.keys(map).sort().map(feature => [`"${cssFeatureString(feature)}"`, feature]))}
}
func (features CSSFeature) Has(feature CSSFeature) bool {
\treturn (features & feature) != 0
}
func (features CSSFeature) ApplyOverrides(overrides CSSFeature, mask CSSFeature) CSSFeature {
\treturn (features & ^mask) | (overrides & mask)
}
var cssTable = map[CSSFeature]map[Engine][]versionRange{
${Object.keys(map).sort().map(feature => `\t${feature}: ${cssTableMap(map[feature as CSSFeature]!)},`).join('\n')}
}
// Return all features that are not available in at least one environment
func UnsupportedCSSFeatures(constraints map[Engine][]int) (unsupported CSSFeature) {
\tfor feature, engines := range cssTable {
\t\tif feature == InlineStyle {
\t\t\tcontinue // This is purely user-specified
\t\t}
\t\tfor engine, version := range constraints {
\t\t\tif !engine.IsBrowser() {
\t\t\t\t// Specifying "--target=es2020" shouldn't affect CSS
\t\t\t\tcontinue
\t\t\t}
\t\t\tif versionRanges, ok := engines[engine]; !ok || !isVersionSupported(versionRanges, version) {
\t\t\t\tunsupported |= feature
\t\t\t}
\t\t}
\t}
\treturn
}
type CSSPrefix uint8
const (
${[...prefixNames].sort().map((name, i) => `\t${name}${i ? '' : ' CSSPrefix = 1 << iota'}`).join('\n')}
\tNoPrefix CSSPrefix = 0
)
type prefixData struct {
\t// Note: In some cases, earlier versions did not require a prefix but later
\t// ones do. This is the case for Microsoft Edge for example, which switched
\t// the underlying browser engine from a custom one to the one from Chrome.
\t// However, we assume that users specifying a browser version for CSS mean
\t// "works in this version or newer", so we still add a prefix when a target
\t// is an old Edge version.
\tengine Engine
\twithoutPrefix v
\tprefix CSSPrefix
}
var cssPrefixTable = map[css_ast.D][]prefixData{
${Object.keys(prefixes).sort().map(property => `\tcss_ast.${property}: ${cssPrefixMap(prefixes[property as CSSProperty]!)},`).join('\n')}
}
func CSSPrefixData(constraints map[Engine][]int) (entries map[css_ast.D]CSSPrefix) {
\tfor property, items := range cssPrefixTable {
\t\tprefixes := NoPrefix
\t\tfor engine, version := range constraints {
\t\t\tif !engine.IsBrowser() {
\t\t\t\t// Specifying "--target=es2020" shouldn't affect CSS
\t\t\t\tcontinue
\t\t\t}
\t\t\tfor _, item := range items {
\t\t\t\tif item.engine == engine && (item.withoutPrefix == v{} || compareVersions(item.withoutPrefix, version) > 0) {
\t\t\t\t\tprefixes |= item.prefix
\t\t\t\t}
\t\t\t}
\t\t}
\t\tif prefixes != NoPrefix {
\t\t\tif entries == nil {
\t\t\t\tentries = make(map[css_ast.D]CSSPrefix)
\t\t\t}
\t\t\tentries[property] = prefixes
\t\t}
\t}
\treturn
}
`)
}

0 comments on commit 95feb2e

Please sign in to comment.