mirror of https://github.com/sveltejs/svelte
commit
e413dbf668
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
perf: use `createElement` instead of `createElementNS` for HTML elements
|
||||
@ -0,0 +1,7 @@
|
||||
<!-- generated in ../../../../../packages/svelte/scripts/generate-browser-support.ts. do not edit -->
|
||||
|
||||
| Feature | Chrome/Edge | Firefox | Safari |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `$state.snapshot` | 98 | 94 | 15.4 |
|
||||
| `bind:devicePixelContentBoxSize` | <span style="color: var(--sk-fg-4)">—</span> | 93 | not supported |
|
||||
| `flip` from `svelte/animate` | <span style="color: var(--sk-fg-4)">—</span> | 126 | <span style="color: var(--sk-fg-4)">—</span> |
|
||||
@ -0,0 +1,13 @@
|
||||
<!-- generated in ../../../../../packages/svelte/scripts/generate-browser-support.ts. do not edit -->
|
||||
|
||||
| Browser | Minimum version |
|
||||
| ---------------- | --------------- |
|
||||
| Chrome/Edge | 87 |
|
||||
| Firefox | 83 |
|
||||
| Safari | 14 |
|
||||
| Opera | 73 |
|
||||
| Opera (Android) | 62 |
|
||||
| Samsung Internet | 14.0 |
|
||||
| Android WebView | 87 |
|
||||
|
||||
> [!NOTE] This equates to a <a href="https://web-platform-dx.github.io/baseline/">Baseline</a> target of 2020.
|
||||
@ -0,0 +1,30 @@
|
||||
---
|
||||
title: Browser support
|
||||
---
|
||||
|
||||
The table below shows the minimum browser versions Svelte's runtime and compiled output are expected to work in.
|
||||
|
||||
@include .generated/browser-support.md
|
||||
|
||||
These numbers describe what Svelte's output _requires_ in order to run — they're derived from the APIs the code uses, not from a list of browsers the team commits to testing.
|
||||
|
||||
## What is covered
|
||||
|
||||
- **Svelte's runtime.** Everything you import from `svelte` or its subpackages, in the form your bundler ships to the browser.
|
||||
- **Compiler output.** The JavaScript the Svelte compiler emits from your `.svelte` files, including the DOM operations, bindings and transitions used in your components.
|
||||
|
||||
## What is not covered
|
||||
|
||||
- **Your own code** inside `<script>` blocks or `.svelte.js` files. If you use newer browser APIs the table will not reflect them — configure your own [browserslist](https://github.com/browserslist/browserslist) and polyfills accordingly.
|
||||
- **SvelteKit**, adapters and build tooling. See the [SvelteKit docs](https://svelte.dev/docs/kit) for the browser support story there.
|
||||
- **Internet Explorer 11.** Svelte's runtime relies on `Proxy`, which cannot be polyfilled. IE11 is not supported and there is no path to making it work.
|
||||
|
||||
## Per-feature browser requirements
|
||||
|
||||
Some Svelte features rely on browser APIs that exceed the floor above. The runtime still loads on older browsers — modern bundlers tree-shake the affected code when the feature is unused — but if you use one of these features, you need the higher minimum version listed here.
|
||||
|
||||
@include .generated/browser-support-features.md
|
||||
|
||||
## How this page stays accurate
|
||||
|
||||
The minimum versions can only move forward in a minor or major release, and any change is recorded in the [changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md). Every Svelte feature — bindings, runes, directives, and module exports — is checked against the [web-features](https://www.npmjs.com/package/web-features) Baseline dataset on every pull request, and the build fails if a change requires newer browsers than the page reflects.
|
||||
@ -0,0 +1,417 @@
|
||||
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));
|
||||
}
|
||||
@ -0,0 +1,874 @@
|
||||
// Regenerates `documentation/docs/07-misc/05-browser-support.md`.
|
||||
//
|
||||
// Pipeline:
|
||||
// 1. Bundle each runtime entry point with rollup using production export
|
||||
// conditions, then walk the resulting JS with TypeScript's compiler
|
||||
// API + TypeChecker. The walker (see `browser-support.detector.js`)
|
||||
// flags any web-features ID the runtime references.
|
||||
// 2. Verify each entry in `BEHAVIORAL_IGNORE` is still flagged by the
|
||||
// detector — if not, the entry can be removed.
|
||||
// 3. Enumerate every user-facing feature (each `bind:*`, every public
|
||||
// subpackage export, every rune from the compiler's `RUNES` array,
|
||||
// and the handful of directives that need their own fixtures). For
|
||||
// each: compile, bundle, walk. If the bundle requires browser
|
||||
// versions newer than the runtime floor, emit a row in the
|
||||
// conditional-features table. Blind-spot regexes pick up APIs the
|
||||
// AST walker can't see (string-literal constructor options,
|
||||
// `getComputedStyle(...).zoom` reads).
|
||||
// 4. Translate floors into concrete browser versions via `web-features`
|
||||
// data (exact per-feature versions), falling back to
|
||||
// `baseline-browser-mapping` for year-only resolution.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { rollup, type OutputChunk } from 'rollup';
|
||||
import virtual from '@rollup/plugin-virtual';
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||
import { getCompatibleVersions } from 'baseline-browser-mapping';
|
||||
import {
|
||||
detect_features,
|
||||
detect_features_in_text,
|
||||
versions_for_feature,
|
||||
baseline_year_for_feature,
|
||||
register_extra_rules
|
||||
} from './browser-support.detector.js';
|
||||
import { binding_properties } from '../src/compiler/phases/bindings.js';
|
||||
import { RUNES } from '../src/utils.js';
|
||||
import { compile as svelte_compile } from '../src/compiler/index.js';
|
||||
|
||||
type BindingProperty = import('../src/compiler/phases/bindings.js').BindingProperty;
|
||||
type PackageExport = string | { browser?: string; default?: string };
|
||||
type CompilerFixture = { filename: string; code: string };
|
||||
type Feature = { name: string; source: string; kind: 'svelte' | 'js' };
|
||||
type BrowserVersions = Record<string, string | null>;
|
||||
type RuntimeFloor = number | 'newly';
|
||||
type ConditionalRow = {
|
||||
name: string;
|
||||
versions: BrowserVersions;
|
||||
baseline_year: RuntimeFloor;
|
||||
};
|
||||
|
||||
// Supplemental detection rules for APIs `web-features` doesn't track
|
||||
// yet. Each rule is checked with full TS type-aware precision — the
|
||||
// only reason it lives here instead of being auto-derived is that no
|
||||
// compat key in `web-features` covers the API.
|
||||
register_extra_rules([
|
||||
{
|
||||
// `getComputedStyle(current).zoom` walk in `svelte/animate`'s
|
||||
// `flip` fallback path. Firefox didn't expose `.zoom` on
|
||||
// `CSSStyleDeclaration` until v126 (May 2024) — pre-126 the read
|
||||
// yields an empty string, breaking the animation math. No entry
|
||||
// for it exists in `web-features` (CSS `zoom` is an IDL accessor
|
||||
// without its own Baseline feature record).
|
||||
receiver_type: 'CSSStyleDeclaration',
|
||||
member: 'zoom',
|
||||
feature_id: 'extra:css-zoom-read',
|
||||
name: 'CSS zoom property reads (getComputedStyle(...).zoom)',
|
||||
baseline_year: 2024,
|
||||
versions: { firefox: '126' }
|
||||
},
|
||||
{
|
||||
// `box: 'device-pixel-content-box'` in `bind_resize_observer`
|
||||
// (size.js). The TS DOM lib declares the option value as part of
|
||||
// the `ResizeObserverBoxOptions` union — we could check the
|
||||
// contextual type, but matching the literal value is enough since
|
||||
// the string is too specific to occur incidentally. Per MDN BCD:
|
||||
// - constructor option: Chrome 84, Firefox 93, Safari 15.4
|
||||
// - `ResizeObserverEntry.devicePixelContentBoxSize`: Safari NOT
|
||||
// SUPPORTED (`version_added: false`)
|
||||
// Safari therefore silently accepts the option from 15.4 onwards
|
||||
// but never exposes the matching entry property, so the binding
|
||||
// reads `undefined` on any Safari.
|
||||
string_literal: 'device-pixel-content-box',
|
||||
feature_id: 'extra:device-pixel-content-box',
|
||||
name: 'ResizeObserver `box: device-pixel-content-box` option + `entry.devicePixelContentBoxSize`',
|
||||
baseline_year: 2023,
|
||||
versions: {
|
||||
chrome: '84',
|
||||
edge: '84',
|
||||
firefox: '93',
|
||||
safari: null,
|
||||
safari_ios: null
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pkg_dir = path.resolve(__dirname, '..');
|
||||
const repo_root = path.resolve(pkg_dir, '..', '..');
|
||||
const docs_dir = path.join(repo_root, 'documentation/docs/07-misc/.generated');
|
||||
const snapshot_dir = path.join(pkg_dir, 'tests/snapshot/samples');
|
||||
const tmp_dir = path.join(__dirname, '_baseline');
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(pkg_dir, 'package.json'), 'utf-8')) as {
|
||||
exports: Record<string, PackageExport>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Suppressions that should NEVER affect the floor regardless of whether
|
||||
* the runtime currently uses the API. Two reasons an entry belongs here:
|
||||
*
|
||||
* - The `web-features` dataset misclassifies the API (e.g.
|
||||
* `devicepixelratio` is marked Baseline `false` because Safari is
|
||||
* missing from its `support` map, but the property has shipped in
|
||||
* every Safari for over a decade).
|
||||
* - Svelte feature-detects the API at runtime with `?.` and degrades
|
||||
* gracefully when it's unavailable. Example: `trusted-types` in
|
||||
* `src/internal/client/dom/reconciler.js`.
|
||||
*
|
||||
* These are exempt from the staleness check.
|
||||
*/
|
||||
const SAFE_TO_IGNORE = new Set(['devicepixelratio', 'trusted-types']);
|
||||
|
||||
/**
|
||||
* Suppressions for features that DO live in the runtime but are reached
|
||||
* only via a specific code path documented in the per-feature table on
|
||||
* the docs page. The aggregate scan hides them so the headline floor
|
||||
* reflects "load Svelte and use the basic runtime", not "use every
|
||||
* conditional feature".
|
||||
*
|
||||
* Each entry MUST appear in the conditional-features table. The
|
||||
* staleness check below also verifies the entry is still present in the
|
||||
* runtime — if the detector doesn't flag it, the entry can be removed.
|
||||
*/
|
||||
const BEHAVIORAL_IGNORE = new Set([
|
||||
'structured-clone',
|
||||
'extra:css-zoom-read',
|
||||
'extra:device-pixel-content-box'
|
||||
]);
|
||||
|
||||
/** Aggregate ignore set — used for the headline floor. */
|
||||
const AGGREGATE_IGNORE = new Set([...SAFE_TO_IGNORE, ...BEHAVIORAL_IGNORE]);
|
||||
|
||||
/**
|
||||
* Subpaths in `pkg.exports` whose runtime is Node-only. Can't be derived
|
||||
* from the exports map alone — `./compiler` has a `require:` field that
|
||||
* hints at CJS, but `./server` and `./internal/server` are plain `default`
|
||||
* entries indistinguishable from a browser module.
|
||||
*/
|
||||
const NODE_ONLY_EXPORTS = new Set(['./compiler', './server', './internal/server']);
|
||||
|
||||
/**
|
||||
* Every subpath in `pkg.exports` that ships browser JS. Type-only entries
|
||||
* (`./action`, `./elements`) and the `./package.json` re-export filter out
|
||||
* naturally on the `.js` check; only Node-only subpaths need an explicit
|
||||
* exception, so new browser exports are picked up automatically.
|
||||
*/
|
||||
function browser_subpaths(): string[] {
|
||||
const subpaths: string[] = [];
|
||||
for (const [subpath, conditions] of Object.entries(pkg.exports)) {
|
||||
if (NODE_ONLY_EXPORTS.has(subpath)) continue;
|
||||
if (typeof conditions !== 'object' || conditions === null) continue;
|
||||
const file = conditions.browser ?? conditions.default;
|
||||
if (typeof file !== 'string' || !file.endsWith('.js')) continue;
|
||||
subpaths.push(subpath);
|
||||
}
|
||||
return subpaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* `.` → `svelte`, `./animate` → `svelte/animate`, etc.
|
||||
*/
|
||||
function importee_for(subpath: string): string {
|
||||
return subpath === '.' ? 'svelte' : `svelte${subpath.slice(1)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a subpath represents a public, user-facing subpackage whose
|
||||
* named exports should each get their own per-feature fixture. Excludes
|
||||
* the main entry (covered by the aggregate scan), the `./legacy` shim,
|
||||
* and everything under `./internal/`.
|
||||
*/
|
||||
function is_public_subpackage(subpath: string): boolean {
|
||||
return subpath !== '.' && subpath !== './legacy' && !subpath.startsWith('./internal');
|
||||
}
|
||||
|
||||
/**
|
||||
* For each public subpackage, dynamically import the module and return
|
||||
* its named exports. Driven entirely by `pkg.exports`, so a new
|
||||
* subpackage is picked up the next time the script runs.
|
||||
*/
|
||||
async function enumerate_subpackage_exports(): Promise<Record<string, string[]>> {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const subpath of browser_subpaths()) {
|
||||
if (!is_public_subpackage(subpath)) continue;
|
||||
const module_id = importee_for(subpath);
|
||||
try {
|
||||
const ns = await import(module_id);
|
||||
const names = Object.keys(ns)
|
||||
.filter((k) => k !== 'default')
|
||||
.sort();
|
||||
if (names.length > 0) result[module_id] = names;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(` (could not enumerate ${module_id}: ${message.split('\n')[0]})`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const rune_fixtures: Record<(typeof RUNES)[number], string> = {
|
||||
$state: `<script>let v = $state(0); console.log(v);</script>`,
|
||||
'$state.raw': `<script>let v = $state.raw({}); console.log(v);</script>`,
|
||||
'$state.eager': `<script>let v = $state.eager(0); console.log(v);</script>`,
|
||||
'$state.snapshot': `<script>const v = $state({}); const snap = $state.snapshot(v); console.log(snap);</script>`,
|
||||
$derived: `<script>let a = $state(0); let d = $derived(a + 1); console.log(d);</script>`,
|
||||
'$derived.by': `<script>let a = $state(0); let d = $derived.by(() => a + 1); console.log(d);</script>`,
|
||||
$props: `<script>let { x } = $props(); console.log(x);</script>`,
|
||||
'$props.id': `<script>const id = $props.id(); console.log(id);</script>`,
|
||||
$bindable: `<script>let { v = $bindable() } = $props(); console.log(v);</script>`,
|
||||
$effect: `<script>$effect(() => { console.log('e'); });</script>`,
|
||||
'$effect.pre': `<script>$effect.pre(() => { console.log('p'); });</script>`,
|
||||
'$effect.tracking': `<script>$effect(() => { console.log($effect.tracking()); });</script>`,
|
||||
'$effect.root': `<script>const stop = $effect.root(() => () => {}); stop();</script>`,
|
||||
'$effect.pending': `<script>$effect(() => { console.log($effect.pending()); });</script>`,
|
||||
$inspect: `<script>let v = $state(0); $inspect(v);</script>`,
|
||||
'$inspect().with': `<script>let v = $state(0); $inspect(v).with(() => {});</script>`,
|
||||
'$inspect.trace': `<script>$effect(() => { $inspect.trace(); });</script>`,
|
||||
$host: `<svelte:options customElement="x-y" />\n<script>const h = $host(); console.log(h);</script>`
|
||||
};
|
||||
|
||||
function rune_fixture(rune: (typeof RUNES)[number]): string {
|
||||
if (!Object.hasOwn(rune_fixtures, rune)) {
|
||||
throw new Error(`Fixture missing for ${rune}`);
|
||||
}
|
||||
|
||||
return rune_fixtures[rune];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiled-fixture sources for directives. Bindings are covered by the
|
||||
* `binding_properties` enumeration; transitions, animate, actions, and
|
||||
* `@attach` need explicit fixtures because they require accompanying
|
||||
* imports or surrounding markup.
|
||||
*/
|
||||
const TESTED_DIRECTIVES = [
|
||||
{
|
||||
name: '`transition:` / `in:` / `out:`',
|
||||
source: `<script>import { fade } from 'svelte/transition'; let show = $state(false);</script>{#if show}<div transition:fade></div>{/if}`
|
||||
},
|
||||
{
|
||||
name: '`animate:`',
|
||||
source: `<script>const flip = () => {}; let items = $state([1,2,3]);</script>{#each items as item (item)}<div animate:flip>{item}</div>{/each}`
|
||||
},
|
||||
{
|
||||
name: '`use:` actions',
|
||||
source: `<script>function action(node){return {destroy(){}}}</script><div use:action></div>`
|
||||
},
|
||||
{
|
||||
name: '`@attach`',
|
||||
source: `<script>const attachment = (node) => () => {};</script><div {@attach attachment}></div>`
|
||||
},
|
||||
{
|
||||
name: '`{@html ...}`',
|
||||
source: `<script>let html = $state('<b>x</b>');</script>{@html html}`
|
||||
},
|
||||
{
|
||||
name: 'Custom elements (`<svelte:options customElement>`)',
|
||||
source: `<svelte:options customElement="my-el" />\n<div></div>`
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Filesystem-safe identifier for an importee like `svelte/internal/client`.
|
||||
*/
|
||||
function safe_name(importee: string): string {
|
||||
return importee.replace(/[^a-z0-9]+/gi, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle an entry the way users receive it, so we scan the same code the
|
||||
* browser does. Mirrors `check-treeshakeability.js`.
|
||||
*
|
||||
* `entry_code` is virtual module source: typically `export * from
|
||||
* 'svelte/...'` for a runtime entry, or compiled fixture JS for a per-feature
|
||||
* scan. `silent` suppresses rollup's circular-dependency warnings — used for
|
||||
* fixture bundles where they're known and noisy.
|
||||
*/
|
||||
async function bundle(entry_code: string, options: { silent?: boolean } = {}): Promise<string> {
|
||||
const built = await rollup({
|
||||
input: '__entry__',
|
||||
plugins: [
|
||||
virtual({ __entry__: entry_code }),
|
||||
{
|
||||
name: 'resolve-svelte',
|
||||
resolveId(id: string) {
|
||||
if (id.startsWith('svelte')) {
|
||||
const entry = pkg.exports[id.replace('svelte', '.')];
|
||||
if (!entry) return;
|
||||
if (typeof entry === 'string') return path.resolve(pkg_dir, entry);
|
||||
const file = entry.browser ?? entry.default;
|
||||
if (file) return path.resolve(pkg_dir, file);
|
||||
}
|
||||
}
|
||||
},
|
||||
nodeResolve({ exportConditions: ['production', 'import', 'browser', 'default'] })
|
||||
],
|
||||
// Treat optional peers / Node-only branches as external so we only scan
|
||||
// code that actually runs in the browser.
|
||||
external: ['esm-env'],
|
||||
onwarn: options.silent
|
||||
? () => {}
|
||||
: (warning, handler) => {
|
||||
if (warning.code === 'CIRCULAR_DEPENDENCY') return;
|
||||
handler(warning);
|
||||
}
|
||||
});
|
||||
|
||||
const { output } = await built.generate({ format: 'esm' });
|
||||
await built.close();
|
||||
|
||||
return output
|
||||
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
|
||||
.map((chunk) => chunk.code)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every compiler-emitted client file from the snapshot tests. These
|
||||
* fixtures cover the full range of patterns the compiler emits — bindings,
|
||||
* transitions, `<svelte:element>`, async derived, hydration markers, etc.
|
||||
*/
|
||||
function load_compiler_output_fixtures(): CompilerFixture[] {
|
||||
const fixtures: CompilerFixture[] = [];
|
||||
|
||||
for (const sample of fs.readdirSync(snapshot_dir)) {
|
||||
const client_dir = path.join(snapshot_dir, sample, '_expected/client');
|
||||
if (!fs.existsSync(client_dir)) continue;
|
||||
|
||||
for (const file of fs.readdirSync(client_dir)) {
|
||||
if (!file.endsWith('.js')) continue;
|
||||
fixtures.push({
|
||||
filename: `${sample}/${file}`,
|
||||
code: fs.readFileSync(path.join(client_dir, file), 'utf-8')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fixtures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine per-feature version data into a single Record. Takes the max
|
||||
* (strictest) version per browser across the input feature IDs.
|
||||
*
|
||||
* `null` propagates as "not supported" — if any contributing feature
|
||||
* marks a browser unsupported, the merged record does too.
|
||||
*
|
||||
* Returns `null` if NONE of the IDs have versions in `web-features` or
|
||||
* supplemental rules, so callers can fall back to year-based mapping.
|
||||
*/
|
||||
function versions_from_features(ids: Iterable<string>): BrowserVersions | null {
|
||||
const merged: BrowserVersions = {};
|
||||
let any_found = false;
|
||||
|
||||
for (const id of ids) {
|
||||
const support = versions_for_feature(id);
|
||||
if (!support) continue;
|
||||
any_found = true;
|
||||
for (const [browser, version] of Object.entries(support)) {
|
||||
const current = merged[browser];
|
||||
// `null` means "not supported"; propagate it directly.
|
||||
if (version === null) {
|
||||
merged[browser] = null;
|
||||
continue;
|
||||
}
|
||||
if (current === null) continue; // already known unsupported
|
||||
if (current === undefined || Number(version) > Number(current)) {
|
||||
merged[browser] = version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return any_found ? merged : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest baseline year among `detected`, and the set of feature IDs that
|
||||
* drove it. Used both for the aggregate runtime floor and for per-fixture
|
||||
* scans — the two differ only in their ignore set.
|
||||
*/
|
||||
function compute_floor(
|
||||
detected: Iterable<string>,
|
||||
ignore: Set<string>
|
||||
): { year: number; drivers: Set<string> } {
|
||||
let year = 0;
|
||||
const drivers = new Set<string>();
|
||||
for (const id of detected) {
|
||||
if (ignore.has(id)) continue;
|
||||
const y = baseline_year_for_feature(id);
|
||||
if (!y) continue;
|
||||
if (y > year) {
|
||||
year = y;
|
||||
drivers.clear();
|
||||
}
|
||||
if (y === year) drivers.add(id);
|
||||
}
|
||||
return { year, drivers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the TS-based detector across the runtime bundles and the compiler-
|
||||
* output fixtures, then compute the highest baseline year among the
|
||||
* detected features (after subtracting `AGGREGATE_IGNORE`).
|
||||
*
|
||||
* `runtime_files` are absolute paths to runtime bundle files. Returns the
|
||||
* minimum Baseline year the combined code satisfies.
|
||||
*/
|
||||
function find_minimum_target(
|
||||
runtime_files: string[],
|
||||
compiler_fixtures: CompilerFixture[]
|
||||
): number {
|
||||
// Type-aware walk over the runtime bundles.
|
||||
const detected = detect_features(runtime_files);
|
||||
|
||||
// Syntax-only walk over the compiler-output fixtures (text only, no
|
||||
// program context; the bare TS source-file parser handles the syntax
|
||||
// features the compiler emits).
|
||||
for (const fixture of compiler_fixtures) {
|
||||
for (const id of detect_features_in_text(fixture.code)) detected.add(id);
|
||||
}
|
||||
|
||||
const { year, drivers } = compute_floor(detected, AGGREGATE_IGNORE);
|
||||
// Floor at 2015 so the docs never claim a pre-ES6 target if every
|
||||
// detected feature happens to lack a Baseline year.
|
||||
const final_year = Math.max(year, 2015);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` → ${final_year} (features that drove the floor:)`);
|
||||
for (const id of [...drivers].sort()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` - ${id}`);
|
||||
}
|
||||
|
||||
return final_year;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify every entry in `BEHAVIORAL_IGNORE` is actually used by the
|
||||
* runtime. Without this, a behavioural suppression can outlive the API
|
||||
* it suppresses — the comment stays in the config pointing at code that
|
||||
* no longer exists.
|
||||
*
|
||||
* `SAFE_TO_IGNORE` entries are exempt: they're safe to carry regardless
|
||||
* of whether the runtime currently uses the API.
|
||||
*
|
||||
*/
|
||||
function validate_ignore_features(runtime_files: string[]): void {
|
||||
if (BEHAVIORAL_IGNORE.size === 0) return;
|
||||
const detected = detect_features(runtime_files);
|
||||
const stale = [...BEHAVIORAL_IGNORE].filter((id) => !detected.has(id));
|
||||
if (stale.length > 0) {
|
||||
throw new Error(
|
||||
`BEHAVIORAL_IGNORE contains entries that the detector does not flag — ` +
|
||||
`they can be removed:\n` +
|
||||
stale.map((id) => ` - ${id}`).join('\n') +
|
||||
`\n\nEdit \`packages/svelte/scripts/generate-browser-support.js\` ` +
|
||||
`and delete the stale entries. If the API was removed from the runtime ` +
|
||||
`as part of this change, that is exactly the intended signal.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of user-facing features to test for conditional
|
||||
* floor bumps. Each feature gets a self-contained fixture, compiled and
|
||||
* bundled like real user code, then scanned. If the bundle's floor
|
||||
* exceeds the runtime floor, a row is auto-emitted in the docs.
|
||||
*
|
||||
* `subpackage_exports` maps subpath → list of exported symbols, produced by
|
||||
* `enumerate_subpackage_exports`. Passed in rather than computed here so the
|
||||
* dynamic-import discovery can happen once in `main`.
|
||||
*/
|
||||
function enumerate_features(subpackage_exports: Record<string, string[]>): Feature[] {
|
||||
const features: Feature[] = [];
|
||||
|
||||
// Every `bind:*` accepted by the compiler. Element selection respects
|
||||
// the `valid_elements` constraint declared in `binding_properties`.
|
||||
for (const [name, props] of Object.entries(binding_properties)) {
|
||||
const fixture = binding_fixture(name, props);
|
||||
if (fixture) {
|
||||
features.push({
|
||||
name: `\`bind:${name}\``,
|
||||
kind: 'svelte',
|
||||
source: fixture
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [module, exports] of Object.entries(subpackage_exports)) {
|
||||
for (const exp of exports) {
|
||||
features.push({
|
||||
name: `\`${exp}\` from \`${module}\``,
|
||||
kind: 'js',
|
||||
source: `import { ${exp} } from '${module}'; export const _ = ${exp};`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const rune of RUNES) {
|
||||
features.push({
|
||||
name: `\`${rune}\``,
|
||||
kind: 'svelte',
|
||||
source: rune_fixture(rune)
|
||||
});
|
||||
}
|
||||
|
||||
for (const directive of TESTED_DIRECTIVES) {
|
||||
features.push({ name: directive.name, kind: 'svelte', source: directive.source });
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the `.svelte` source for a single binding fixture. Returns
|
||||
* `null` for bindings the compiler treats as elements rather than
|
||||
* properties (none currently, but defensive).
|
||||
*
|
||||
*/
|
||||
function binding_fixture(name: string, props: BindingProperty): string {
|
||||
// Map declared `valid_elements` to a concrete element + minimal attrs
|
||||
// so the compiler accepts the binding.
|
||||
const tag = (props.valid_elements ?? ['div'])[0];
|
||||
|
||||
// Pick a sensible initial value and attribute set per binding.
|
||||
const reactive = `let v = $state();`;
|
||||
|
||||
if (tag === 'svelte:window') {
|
||||
return `<script>${reactive}</script><svelte:window bind:${name}={v} />`;
|
||||
}
|
||||
if (tag === 'svelte:document') {
|
||||
return `<script>${reactive}</script><svelte:document bind:${name}={v} />`;
|
||||
}
|
||||
if (tag === 'input') {
|
||||
// `bind:checked` requires type="checkbox"|"radio"; `bind:group` too.
|
||||
const type =
|
||||
name === 'checked' || name === 'indeterminate'
|
||||
? ' type="checkbox"'
|
||||
: name === 'group'
|
||||
? ' type="radio" value="a"'
|
||||
: name === 'files'
|
||||
? ' type="file"'
|
||||
: '';
|
||||
return `<script>${reactive}</script><input${type} bind:${name}={v} />`;
|
||||
}
|
||||
if (tag === 'details') {
|
||||
return `<script>${reactive}</script><details bind:${name}={v}><summary>x</summary></details>`;
|
||||
}
|
||||
|
||||
return `<script>${reactive}</script><${tag} bind:${name}={v}></${tag}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a `.svelte` fixture to JS (no-op for `.js` fixtures), then
|
||||
* bundle the result through the shared `bundle` helper. Fixtures are tiny
|
||||
* so circular-dep warnings from the Svelte runtime are silenced.
|
||||
*
|
||||
*/
|
||||
async function bundle_fixture(feature: Feature): Promise<string> {
|
||||
const entry_code =
|
||||
feature.kind === 'svelte'
|
||||
? svelte_compile(feature.source, {
|
||||
generate: 'client',
|
||||
filename: 'Fixture.svelte',
|
||||
dev: false
|
||||
}).js.code
|
||||
: feature.source;
|
||||
return bundle(entry_code, { silent: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect features in a single fixture bundle and report the per-fixture
|
||||
* floor year along with the IDs that drove it. Used for the per-feature
|
||||
* conditional table.
|
||||
*
|
||||
* `fixture_file` is the absolute path to the `.ts` bundle.
|
||||
*/
|
||||
function scan_fixture(fixture_file: string): {
|
||||
year: number;
|
||||
driving_ids: string[];
|
||||
} {
|
||||
const { year, drivers } = compute_floor(detect_features([fixture_file]), SAFE_TO_IGNORE);
|
||||
|
||||
return {
|
||||
year,
|
||||
driving_ids: [...drivers]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate every feature, bundle its fixture, scan it. Return the rows
|
||||
* that need to appear in the conditional-features table.
|
||||
*/
|
||||
async function find_all_conditional_features(
|
||||
runtime_floor: RuntimeFloor,
|
||||
subpackage_exports: Record<string, string[]>
|
||||
): Promise<ConditionalRow[]> {
|
||||
const runtime_year = typeof runtime_floor === 'number' ? runtime_floor : Infinity;
|
||||
const features = enumerate_features(subpackage_exports);
|
||||
const rows: ConditionalRow[] = [];
|
||||
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
const feature = features[i];
|
||||
process.stdout.write(`\r ${i + 1}/${features.length} ${feature.name}`.padEnd(80));
|
||||
|
||||
let bundle_code;
|
||||
try {
|
||||
bundle_code = await bundle_fixture(feature);
|
||||
} catch {
|
||||
continue; // some fixtures (rare element combos) may fail to compile
|
||||
}
|
||||
|
||||
// Write the bundle so the type-aware scanner can resolve its types.
|
||||
const fixture_file = path.join(tmp_dir, `fixture_${i}.ts`);
|
||||
fs.writeFileSync(fixture_file, bundle_code);
|
||||
|
||||
const scanned = scan_fixture(fixture_file);
|
||||
const final_year = scanned.year;
|
||||
|
||||
// Skip features at or below the runtime floor — they don't need a row.
|
||||
if (final_year <= runtime_year || final_year === 0) continue;
|
||||
|
||||
// Use exact per-feature versions where available (from web-features
|
||||
// or supplemental rules), falling back to the conservative year
|
||||
// mapping only if no feature has explicit version data.
|
||||
let versions = versions_from_features(scanned.driving_ids);
|
||||
if (!versions) {
|
||||
try {
|
||||
versions = browser_versions_for(final_year);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
name: feature.name,
|
||||
versions,
|
||||
baseline_year: final_year
|
||||
});
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the per-feature browser-requirements table from the auto-detected
|
||||
* rows. Sorted by Safari floor descending, then alphabetically.
|
||||
*/
|
||||
function render_conditional_table(rows: ConditionalRow[], runtime_floor: RuntimeFloor): string {
|
||||
if (rows.length === 0) {
|
||||
return '_No features currently require browser versions newer than the runtime floor._';
|
||||
}
|
||||
|
||||
const browsers = [
|
||||
['chrome', 'Chrome/Edge'],
|
||||
['firefox', 'Firefox'],
|
||||
['safari', 'Safari']
|
||||
] as const;
|
||||
|
||||
const runtime_versions = browser_versions_for(runtime_floor);
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const sa = Number(a.versions.safari ?? '0');
|
||||
const sb = Number(b.versions.safari ?? '0');
|
||||
if (sb !== sa) return sb - sa;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const header = '| Feature | ' + browsers.map(([, label]) => `${label}`).join(' | ') + ' |';
|
||||
const sep = '| --- |' + browsers.map(() => ' ---: |').join('');
|
||||
|
||||
const body = sorted.map((entry) => {
|
||||
const cells = browsers.map(([key]) => {
|
||||
const v = entry.versions[key];
|
||||
if (v === null) return 'not supported';
|
||||
if (v === undefined) return '<span style="color: var(--sk-fg-4)">—</span>';
|
||||
const floor_v = runtime_versions[key];
|
||||
return floor_v && Number(v) <= Number(floor_v)
|
||||
? '<span style="color: var(--sk-fg-4)">—</span>'
|
||||
: v;
|
||||
});
|
||||
return `| ${entry.name} | ${cells.join(' | ')} |`;
|
||||
});
|
||||
|
||||
return [header, sep, ...body].join('\n');
|
||||
}
|
||||
|
||||
function browser_versions_for(target: RuntimeFloor): Record<string, string> {
|
||||
// `targetYear` returns the minimum versions in which every feature that
|
||||
// reached Baseline by the end of that year is supported. If the lint
|
||||
// search fell through to `'newly'`, we use the current year — that gives
|
||||
// the most recent Newly-available cutoff, which is the strongest
|
||||
// statement `baseline-browser-mapping` is able to make.
|
||||
const target_year = typeof target === 'number' ? target : new Date().getFullYear();
|
||||
|
||||
const versions = getCompatibleVersions({
|
||||
targetYear: target_year,
|
||||
includeDownstreamBrowsers: true
|
||||
});
|
||||
|
||||
// The core Baseline browsers plus the downstream browsers worth listing
|
||||
// in the docs. Downstream browsers come from `baseline-browser-mapping`'s
|
||||
// dataset and represent the highest-traffic Chromium derivatives; the
|
||||
// long tail (UC, QQ, Yandex, in-app Facebook/Instagram browsers, etc.)
|
||||
// is omitted to keep the table focused.
|
||||
const visible_browsers = new Set([
|
||||
'chrome',
|
||||
'chrome_android',
|
||||
'edge',
|
||||
'firefox',
|
||||
'firefox_android',
|
||||
'safari',
|
||||
'safari_ios',
|
||||
'opera',
|
||||
'opera_android',
|
||||
'samsunginternet_android',
|
||||
'webview_android'
|
||||
]);
|
||||
|
||||
const suffixes = ['_android', '_ios'];
|
||||
|
||||
const lookup: Record<string, string> = {};
|
||||
outer: for (const { browser, version } of versions) {
|
||||
if (visible_browsers.has(browser)) {
|
||||
for (const suffix of suffixes) {
|
||||
// skip e.g. 'Chrome (Android)' if it matches Chrome
|
||||
if (browser.endsWith(suffix) && version === lookup[browser.replace(suffix, '')]) {
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
|
||||
lookup[browser] = version;
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function render_table(versions: Record<string, string>, target: RuntimeFloor): string {
|
||||
// Chrome and Edge ship from the same engine and historically resolve to the
|
||||
// same Baseline version. Collapse them into one row when they match, but
|
||||
// fall back to listing them separately if they ever drift.
|
||||
const chrome_edge: [string, string] | null =
|
||||
versions.chrome && versions.chrome === versions.edge ? ['Chrome/Edge', versions.chrome] : null;
|
||||
|
||||
const base_rows: Array<[string, string]> = chrome_edge
|
||||
? [chrome_edge, ['Chrome (Android)', versions.chrome_android]]
|
||||
: [
|
||||
['Chrome', versions.chrome],
|
||||
['Chrome (Android)', versions.chrome_android],
|
||||
['Edge', versions.edge]
|
||||
];
|
||||
|
||||
const rows = [
|
||||
...base_rows,
|
||||
['Firefox', versions.firefox],
|
||||
['Firefox (Android)', versions.firefox_android],
|
||||
['Safari', versions.safari],
|
||||
['Safari (iOS)', versions.safari_ios],
|
||||
['Opera', versions.opera],
|
||||
['Opera (Android)', versions.opera_android],
|
||||
['Samsung Internet', versions.samsunginternet_android],
|
||||
['Android WebView', versions.webview_android]
|
||||
].filter(([label, version]) => version !== undefined) as Array<[string, string]>;
|
||||
|
||||
const headings = ['Browser', 'Minimum version'];
|
||||
|
||||
const widths = headings.map((heading, i) =>
|
||||
Math.max(heading.length, ...rows.map((r) => String(r[i]).length))
|
||||
);
|
||||
|
||||
const pad = (s: string, n: number) => s + ' '.repeat(Math.max(0, n - s.length));
|
||||
|
||||
const header = `| ${headings.map((heading, i) => pad(heading, widths[i])).join(' | ')} |`;
|
||||
const sep = `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`;
|
||||
const body = rows
|
||||
.map(([a, b]) => `| ${pad(a, widths[0])} | ${pad(String(b), widths[1])} |`)
|
||||
.join('\n');
|
||||
|
||||
const target_label = target === 'newly' ? '"newly available"' : target;
|
||||
|
||||
return `${header}\n${sep}\n${body}\n\n> [!NOTE] This equates to a <a href="https://web-platform-dx.github.io/baseline/">Baseline</a> target of ${target_label}.`;
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
async function main() {
|
||||
console.log('Preparing scratch directory…');
|
||||
// Wipe and recreate so stale bundles can't leak into the next scan.
|
||||
fs.rmSync(tmp_dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(tmp_dir, { recursive: true });
|
||||
|
||||
try {
|
||||
console.log('Bundling runtime entries…');
|
||||
const runtime_files: string[] = [];
|
||||
for (const importee of browser_subpaths().map(importee_for)) {
|
||||
// `import * as` + re-export keeps default and named exports
|
||||
// alive, so flag modules (only a default export) don't produce
|
||||
// empty chunks but their code still ends up in the scan.
|
||||
const code = await bundle(`import * as __ns from '${importee}'; export default __ns;`);
|
||||
const file = path.join(tmp_dir, `${safe_name(importee)}.ts`);
|
||||
fs.writeFileSync(file, code);
|
||||
runtime_files.push(file);
|
||||
}
|
||||
|
||||
console.log('Loading compiler-output fixtures…');
|
||||
const compiler_fixtures = load_compiler_output_fixtures();
|
||||
console.log(` (${compiler_fixtures.length} fixtures found)`);
|
||||
|
||||
console.log('Searching for the minimum Baseline target (type-aware)…');
|
||||
const target = find_minimum_target(runtime_files, compiler_fixtures);
|
||||
|
||||
console.log('Checking BEHAVIORAL_IGNORE for stale entries…');
|
||||
validate_ignore_features(runtime_files);
|
||||
console.log(' no stale entries');
|
||||
|
||||
console.log('Enumerating subpackage exports…');
|
||||
const subpackage_exports = await enumerate_subpackage_exports();
|
||||
const total_exports = Object.values(subpackage_exports).reduce((n, list) => n + list.length, 0);
|
||||
console.log(
|
||||
` ${total_exports} export(s) across ${Object.keys(subpackage_exports).length} subpackage(s)`
|
||||
);
|
||||
|
||||
console.log('Scanning per-feature fixtures for conditional requirements…');
|
||||
const conditional_rows = await find_all_conditional_features(target, subpackage_exports);
|
||||
console.log(
|
||||
` ${conditional_rows.length} feature(s) require browsers newer than the runtime floor`
|
||||
);
|
||||
|
||||
console.log('Resolving browser versions…');
|
||||
const versions = browser_versions_for(target);
|
||||
|
||||
console.log('Rewriting docs page…');
|
||||
generate('browser-support.md', render_table(versions, target));
|
||||
generate('browser-support-features.md', render_conditional_table(conditional_rows, target));
|
||||
|
||||
console.log('Done.');
|
||||
} finally {
|
||||
// Ensure cleanup happens even on failure — otherwise leftover bundle
|
||||
// files in `scripts/_baseline/` get picked up by `pnpm lint` on the
|
||||
// next CI step and produce spurious naming/no-console errors.
|
||||
fs.rmSync(tmp_dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function generate(file: string, content: string): void {
|
||||
const filename = path.join(docs_dir, file);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
} catch {}
|
||||
|
||||
const backlink = path.relative(filename, fileURLToPath(import.meta.url));
|
||||
|
||||
fs.writeFileSync(filename, `<!-- generated in ${backlink}. do not edit -->\n\n${content}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in new issue