mirror of https://github.com/sveltejs/svelte
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
418 lines
15 KiB
418 lines
15 KiB
import ts from 'typescript';
|
|
import { features } from 'web-features';
|
|
|
|
/**
|
|
* Maps compat-key suffixes under `javascript.operators` and
|
|
* `javascript.statements` (and a few other `javascript.*` subtrees) to
|
|
* detection callbacks. The callback receives a TS AST node and returns
|
|
* true if that node represents the operator or statement.
|
|
*
|
|
* @type {Record<string, (node: ts.Node) => boolean>}
|
|
*/
|
|
const SYNTAX_PREDICATES = {
|
|
nullish_coalescing: (node) =>
|
|
ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken,
|
|
nullish_coalescing_assignment: (node) =>
|
|
ts.isBinaryExpression(node) &&
|
|
node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken,
|
|
logical_or_assignment: (node) =>
|
|
ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken,
|
|
logical_and_assignment: (node) =>
|
|
ts.isBinaryExpression(node) &&
|
|
node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken,
|
|
optional_chaining: (node) =>
|
|
(ts.isPropertyAccessExpression(node) ||
|
|
ts.isElementAccessExpression(node) ||
|
|
ts.isCallExpression(node)) &&
|
|
node.questionDotToken !== undefined,
|
|
spread: (node) => ts.isSpreadElement(node) || ts.isSpreadAssignment(node),
|
|
destructuring: (node) => ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node),
|
|
arrow_functions: (node) => ts.isArrowFunction(node),
|
|
try_catch_optional_binding: (node) =>
|
|
ts.isCatchClause(node) && node.variableDeclaration === undefined,
|
|
async_iteration: (node) => ts.isForOfStatement(node) && node.awaitModifier !== undefined,
|
|
for_await: (node) => ts.isForOfStatement(node) && node.awaitModifier !== undefined,
|
|
private_class_fields: (node) => ts.isPrivateIdentifier(node),
|
|
async_generator_function: (node) =>
|
|
(ts.isFunctionDeclaration(node) ||
|
|
ts.isFunctionExpression(node) ||
|
|
ts.isMethodDeclaration(node)) &&
|
|
node.asteriskToken !== undefined &&
|
|
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false),
|
|
generator_function: (node) =>
|
|
(ts.isFunctionDeclaration(node) ||
|
|
ts.isFunctionExpression(node) ||
|
|
ts.isMethodDeclaration(node)) &&
|
|
node.asteriskToken !== undefined &&
|
|
!(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false),
|
|
async_function: (node) =>
|
|
(ts.isFunctionDeclaration(node) ||
|
|
ts.isFunctionExpression(node) ||
|
|
ts.isArrowFunction(node) ||
|
|
ts.isMethodDeclaration(node)) &&
|
|
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false) &&
|
|
node.asteriskToken === undefined,
|
|
classes: (node) => ts.isClassDeclaration(node) || ts.isClassExpression(node),
|
|
let_const: (node) =>
|
|
ts.isVariableDeclarationList(node) &&
|
|
((node.flags & ts.NodeFlags.Let) !== 0 || (node.flags & ts.NodeFlags.Const) !== 0),
|
|
template_literals: (node) =>
|
|
ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)
|
|
};
|
|
|
|
/**
|
|
* Walk `web-features` once and partition every `compat_features` path
|
|
* into the lookup tables the AST walker uses.
|
|
*/
|
|
function build_detection_maps() {
|
|
/** @type {Map<string, string>} identifier name → feature_id */
|
|
const globals = new Map();
|
|
/** @type {Map<string, Map<string, string>>} type → member → feature_id */
|
|
const members = new Map();
|
|
/** @type {Map<string, string>} string-literal value → feature_id */
|
|
const string_literals = new Map();
|
|
/** @type {Array<{ predicate: (n: ts.Node) => boolean, feature_id: string }>} */
|
|
const syntax_predicates = [];
|
|
|
|
const add_member = (
|
|
/** @type {string} */ type,
|
|
/** @type {string} */ member,
|
|
/** @type {string} */ feature_id
|
|
) => {
|
|
let by_member = members.get(type);
|
|
if (!by_member) {
|
|
by_member = new Map();
|
|
members.set(type, by_member);
|
|
}
|
|
// Only set if not already present — the first feature claiming a
|
|
// (type, member) pair wins. (Multiple features can map to the same
|
|
// pair via duplicated compat paths; we just need any.)
|
|
if (!by_member.has(member)) by_member.set(member, feature_id);
|
|
};
|
|
|
|
for (const [feature_id, feature] of Object.entries(features)) {
|
|
if (!('compat_features' in feature) || !feature.compat_features) continue;
|
|
for (const path of feature.compat_features) {
|
|
const parts = path.split('.');
|
|
|
|
// `api.X` → global identifier (constructor or function), e.g.
|
|
// `api.ResizeObserver`, `api.structuredClone`.
|
|
if (parts[0] === 'api' && parts.length === 2) {
|
|
globals.set(parts[1], feature_id);
|
|
continue;
|
|
}
|
|
|
|
// `api.X.Y` → member access on type X. E.g.
|
|
// `api.HTMLElement.inert`, `api.ResizeObserverEntry.contentBoxSize`.
|
|
if (parts[0] === 'api' && parts.length === 3) {
|
|
add_member(parts[1], parts[2], feature_id);
|
|
continue;
|
|
}
|
|
|
|
// `javascript.builtins.X` → global like Promise, Symbol, Proxy.
|
|
if (parts[0] === 'javascript' && parts[1] === 'builtins' && parts.length === 3) {
|
|
globals.set(parts[2], feature_id);
|
|
continue;
|
|
}
|
|
|
|
// `javascript.builtins.X.Y` → method/property on type X.
|
|
// We also accept the `ArrayConstructor`-style mapping for static
|
|
// methods: when the AST walker sees `Array.from(...)`, the
|
|
// receiver type's symbol name is `ArrayConstructor`, not `Array`.
|
|
if (parts[0] === 'javascript' && parts[1] === 'builtins' && parts.length === 4) {
|
|
add_member(parts[2], parts[3], feature_id);
|
|
add_member(`${parts[2]}Constructor`, parts[3], feature_id);
|
|
continue;
|
|
}
|
|
|
|
// `javascript.*` syntax: try the second segment first (covers
|
|
// `javascript.operators.X` and `javascript.statements.X`), then
|
|
// the last segment (covers `javascript.classes.private_class_fields`
|
|
// and similar).
|
|
if (parts[0] === 'javascript' && parts.length >= 3) {
|
|
const candidates = [parts[2], parts[parts.length - 1]];
|
|
for (const key of candidates) {
|
|
if (Object.hasOwn(SYNTAX_PREDICATES, key)) {
|
|
syntax_predicates.push({ predicate: SYNTAX_PREDICATES[key], feature_id });
|
|
break;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { globals, members, string_literals, syntax_predicates };
|
|
}
|
|
|
|
const MAPS = build_detection_maps();
|
|
|
|
/**
|
|
* Versions and friendly names for synthetic feature IDs registered via
|
|
* `register_extra_rules` — APIs the type-aware walker can see but that
|
|
* `web-features` doesn't (yet) catalogue. `versions_for_feature` consults
|
|
* this map before falling back to `web-features` lookups.
|
|
*
|
|
* @type {Map<string, { name: string, versions: Record<string, string | null>, baseline_year: number | null }>}
|
|
*/
|
|
const EXTRA_FEATURE_INFO = new Map();
|
|
|
|
/**
|
|
* Register additional detection rules for APIs the `web-features` dataset
|
|
* doesn't track yet. Two rule shapes are accepted:
|
|
*
|
|
* - **Member access**: `{ receiver_type, member, ... }` — flags
|
|
* `expr.member` when `expr`'s type resolves to `receiver_type`.
|
|
* Equivalent to a `api.X.Y` rule auto-derived from web-features.
|
|
*
|
|
* - **String literal**: `{ string_literal, ... }` — flags any
|
|
* occurrence of the literal value in source. Used for API options
|
|
* that are string-typed (e.g. `{ box: 'device-pixel-content-box' }`).
|
|
* The walker can be tightened later with a contextual-type check if
|
|
* false positives ever surface; for now an exact match is enough
|
|
* (these strings are too specific to occur incidentally).
|
|
*
|
|
* Each rule contributes its `feature_id`, per-browser versions,
|
|
* baseline year, and display name to the shared lookup tables.
|
|
*
|
|
* @param {Array<{
|
|
* feature_id: string,
|
|
* name: string,
|
|
* baseline_year: number,
|
|
* versions: Record<string, string | null>,
|
|
* receiver_type?: string,
|
|
* member?: string,
|
|
* string_literal?: string
|
|
* }>} rules
|
|
*/
|
|
export function register_extra_rules(rules) {
|
|
for (const rule of rules) {
|
|
if (rule.receiver_type && rule.member) {
|
|
let by_member = MAPS.members.get(rule.receiver_type);
|
|
if (!by_member) {
|
|
by_member = new Map();
|
|
MAPS.members.set(rule.receiver_type, by_member);
|
|
}
|
|
// Don't overwrite an existing web-features rule; that's canonical.
|
|
if (!by_member.has(rule.member)) {
|
|
by_member.set(rule.member, rule.feature_id);
|
|
}
|
|
} else if (rule.string_literal) {
|
|
if (!MAPS.string_literals.has(rule.string_literal)) {
|
|
MAPS.string_literals.set(rule.string_literal, rule.feature_id);
|
|
}
|
|
}
|
|
EXTRA_FEATURE_INFO.set(rule.feature_id, {
|
|
name: rule.name,
|
|
baseline_year: rule.baseline_year,
|
|
versions: rule.versions
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compile bundle files into a single `ts.Program` so type-checking is
|
|
* amortised across all of them.
|
|
*
|
|
* @param {string[]} files
|
|
*/
|
|
function build_program(files) {
|
|
const program = ts.createProgram(files, {
|
|
allowJs: true,
|
|
checkJs: false,
|
|
target: ts.ScriptTarget.ESNext,
|
|
module: ts.ModuleKind.ESNext,
|
|
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
lib: ['lib.esnext.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
|
|
strict: false,
|
|
noEmit: true,
|
|
skipLibCheck: true,
|
|
isolatedModules: true,
|
|
noErrorTruncation: true
|
|
});
|
|
const checker = program.getTypeChecker();
|
|
return { program, checker };
|
|
}
|
|
|
|
/**
|
|
* Collect the names of a type and its base types so a `member` lookup
|
|
* keyed on (say) `HTMLElement` matches a receiver typed as
|
|
* `HTMLDivElement`. Also includes the apparent type to catch primitives
|
|
* (`'foo'` apparent-types to `String`).
|
|
*
|
|
* @param {ts.Type} type
|
|
* @param {ts.TypeChecker} checker
|
|
*/
|
|
function get_type_names(type, checker) {
|
|
const names = new Set();
|
|
const constituents = type.isUnionOrIntersection() ? type.types : [type];
|
|
for (const t of constituents) {
|
|
const symbol = t.getSymbol() ?? t.aliasSymbol;
|
|
if (symbol) names.add(symbol.getName());
|
|
for (const base of t.getBaseTypes?.() ?? []) {
|
|
const base_symbol = base.getSymbol() ?? base.aliasSymbol;
|
|
if (base_symbol) names.add(base_symbol.getName());
|
|
}
|
|
const apparent = checker.getApparentType(t);
|
|
if (apparent && apparent !== t) {
|
|
const apparent_symbol = apparent.getSymbol() ?? apparent.aliasSymbol;
|
|
if (apparent_symbol) names.add(apparent_symbol.getName());
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Cheap test for whether a symbol refers to a global binding (declared
|
|
* in a lib.d.ts or ambient module) rather than a user-defined local.
|
|
*
|
|
* @param {ts.Symbol | undefined} symbol
|
|
*/
|
|
function is_global_binding(symbol) {
|
|
if (!symbol) return true; // unresolved → assume global
|
|
const declarations = symbol.getDeclarations() ?? [];
|
|
if (declarations.length === 0) return true;
|
|
for (const decl of declarations) {
|
|
const file_name = decl.getSourceFile().fileName;
|
|
if (file_name.includes('/lib.') && file_name.endsWith('.d.ts')) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Walk a TS source file emitting feature IDs as they're discovered.
|
|
*
|
|
* @param {ts.SourceFile} source
|
|
* @param {ts.TypeChecker | null} checker
|
|
* When `null`, type-aware checks (member access) are skipped. Used for
|
|
* the compiler-output fixtures which are parsed without a Program.
|
|
* @param {(feature_id: string) => void} emit
|
|
*/
|
|
function walk_source(source, checker, emit) {
|
|
/** @param {ts.Node} node */
|
|
function visit(node) {
|
|
// Syntax predicates run regardless of whether we have a checker.
|
|
for (const { predicate, feature_id } of MAPS.syntax_predicates) {
|
|
if (predicate(node)) emit(feature_id);
|
|
}
|
|
|
|
// Global identifier detection.
|
|
if (
|
|
ts.isIdentifier(node) &&
|
|
!(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) &&
|
|
!ts.isPropertyAssignment(node.parent) &&
|
|
!ts.isMethodDeclaration(node.parent) &&
|
|
!ts.isPropertySignature(node.parent)
|
|
) {
|
|
const feature_id = MAPS.globals.get(node.text);
|
|
if (feature_id) {
|
|
if (!checker || is_global_binding(checker.getSymbolAtLocation(node))) {
|
|
emit(feature_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// String-literal detection. The walker matches by value alone;
|
|
// false positives are theoretically possible but the literal
|
|
// values we care about (e.g. `'device-pixel-content-box'`) are
|
|
// distinctive enough that one hasn't been observed.
|
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
const feature_id = MAPS.string_literals.get(node.text);
|
|
if (feature_id) emit(feature_id);
|
|
}
|
|
|
|
// Member access detection (requires the checker).
|
|
if (checker && ts.isPropertyAccessExpression(node)) {
|
|
const member_name = node.name.text;
|
|
const receiver_type = checker.getTypeAtLocation(node.expression);
|
|
const type_names = get_type_names(receiver_type, checker);
|
|
for (const type_name of type_names) {
|
|
const by_member = MAPS.members.get(type_name);
|
|
if (!by_member) continue;
|
|
const feature_id = by_member.get(member_name);
|
|
if (feature_id) {
|
|
emit(feature_id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
|
|
visit(source);
|
|
}
|
|
|
|
/**
|
|
* Detect features used in a set of bundle files. Returns the union of
|
|
* web-features IDs flagged across all files.
|
|
*
|
|
* @param {string[]} bundle_files Absolute paths to JS/TS files.
|
|
* @returns {Set<string>}
|
|
*/
|
|
export function detect_features(bundle_files) {
|
|
const { program, checker } = build_program(bundle_files);
|
|
const flagged = new Set();
|
|
for (const file of bundle_files) {
|
|
const source = program.getSourceFile(file);
|
|
if (!source) continue;
|
|
walk_source(source, checker, (id) => flagged.add(id));
|
|
}
|
|
return flagged;
|
|
}
|
|
|
|
/**
|
|
* Detect features used in a single in-memory source string. No type
|
|
* checker (so member-based rules silently skip) — useful for the
|
|
* compiler-output fixtures, where syntax-level detection is sufficient.
|
|
*
|
|
* @param {string} source_text
|
|
* @returns {Set<string>}
|
|
*/
|
|
export function detect_features_in_text(source_text) {
|
|
const source = ts.createSourceFile(
|
|
'fixture.js',
|
|
source_text,
|
|
ts.ScriptTarget.ESNext,
|
|
true,
|
|
ts.ScriptKind.JS
|
|
);
|
|
const flagged = new Set();
|
|
walk_source(source, null, (id) => flagged.add(id));
|
|
return flagged;
|
|
}
|
|
|
|
/**
|
|
* Per-browser minimum versions for a feature ID. Consults
|
|
* supplemental rules first (from `register_extra_rules`), then falls
|
|
* back to `web-features`. Returns null when neither has data.
|
|
*
|
|
* @param {string} feature_id
|
|
*/
|
|
export function versions_for_feature(feature_id) {
|
|
const extra = EXTRA_FEATURE_INFO.get(feature_id);
|
|
if (extra) return extra.versions;
|
|
const feature = features[feature_id];
|
|
if (!feature || !('status' in feature)) return null;
|
|
return /** @type {Record<string, string> | null} */ (
|
|
/** @type {unknown} */ (feature.status.support)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Baseline year for a feature. Returns `null` for features without a
|
|
* Baseline date (limited availability, supplemental rules without a
|
|
* year, or absent from the dataset).
|
|
*
|
|
* @param {string} feature_id
|
|
*/
|
|
export function baseline_year_for_feature(feature_id) {
|
|
const extra = EXTRA_FEATURE_INFO.get(feature_id);
|
|
if (extra) return extra.baseline_year;
|
|
const feature = features[feature_id];
|
|
if (!feature || !('status' in feature)) return null;
|
|
const status = /** @type {{ baseline_low_date?: string }} */ (feature.status);
|
|
if (!status.baseline_low_date) return null;
|
|
return Number(status.baseline_low_date.slice(0, 4));
|
|
}
|