Prepare generator for `@webref/idl` 3.85+ and `@webref/css` 8+ (#594)
Updates `js_interop_gen` and `web_generator` to handle new WebIDL, CSS, and Browser Compat Data constructs in upcoming `@webref` and `@mdn` releases, with zero diff in `web/`:
- Resolve `[LegacyWindowAlias]` and `WindowProxy` type references to their canonical IDL declarations via `Translator.resolveIdlTypeName` across `_getRawType`, `_collectDocImports`, and `_getTypeNameRaw`, and assert against `[LegacyWindowAlias]` in `_setInheritance`.
- Prefix `[LegacyNamespace]` interfaces whose names shadow `dart:core` types (e.g. `WebAssembly.Exception` -> `WebAssemblyException` via `Translator.dartClassNameFor`) across declarations, references, `JSPromise<...>`, cross-file imports, union docs, and member-name collision checks while preserving `@JS('WebAssembly.Exception')`.
- Ignore `async_iterable` members, alias `async_sequence` to `JSObject`, support both `@webref/css` 6 and 7+ `listAll()` formats, and inspect all non-flagged/non-preview support statements in `bcd.dart`.
- Install `@latest` for `@webref/*` and `@mdn/browser-compat-data` in `web_generator/bin/update_idl_bindings.dart --update`.
Fixes #480
diff --git a/.gitignore b/.gitignore
index c49b2d4..05f6492 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
js_interop_gen/lib/src/*.js
js_interop_gen/lib/src/*.js.*
+js_interop_gen/lib/src/web_rename_map.json
js_interop_gen/lib/src/node_modules/
web_generator/node_modules
.compile.lock
diff --git a/js_interop_gen/lib/src/bcd.dart b/js_interop_gen/lib/src/bcd.dart
index 24d2ad8..fb730a9 100644
--- a/js_interop_gen/lib/src/bcd.dart
+++ b/js_interop_gen/lib/src/bcd.dart
@@ -182,19 +182,23 @@
String get _browsersDescription => browsers.join(', ');
bool _supportedInBrowser(String browser) {
- final map =
- (_support[browser] is List
- ? (_support[browser] as List).first
- : _support[browser])
- as Map<String, dynamic>;
+ final support = _support[browser];
+ if (support == null) return false;
+ final statements = support is List
+ ? support.cast<Map<String, dynamic>>()
+ : [support as Map<String, dynamic>];
- if (map.containsKey('version_removed')) {
- return false;
+ for (final map in statements) {
+ // Multi-statement entries record historical, flag-gated, prefixed, or
+ // preview implementations alongside shipping support; skip non-default
+ // entries and match any currently shipping, unflagged release.
+ if (map.containsKey('version_removed')) continue;
+ if (map['flags'] != null) continue;
+ if (map['prefix'] != null || map['alternative_name'] != null) continue;
+ final value = map['version_added'];
+ if (value is String && value != 'preview') return true;
+ if (value is bool && value) return true;
}
-
- final value = map['version_added'];
- if (value is String) return true;
- if (value is bool) return value;
return false;
}
diff --git a/js_interop_gen/lib/src/elements.dart b/js_interop_gen/lib/src/elements.dart
index 8f18668..c13862d 100644
--- a/js_interop_gen/lib/src/elements.dart
+++ b/js_interop_gen/lib/src/elements.dart
@@ -119,9 +119,9 @@
type = (idlType.idlType as JSString).toDart;
}
- if (type == 'WindowProxy') type = 'Window';
if (type == 'any') nullable = true;
final translator = Translator.instance!;
+ type = translator.resolveIdlTypeName(type);
final decl = translator.typeToDeclaration[type];
final alias = idlOrBuiltinToJsTypeAliases[type];
assert(decl != null || alias != null);
@@ -306,8 +306,12 @@
MemberName _generateName() {
_finalized = true;
final dartName = _name.name;
- if (dartName == returnType.type ||
- parameters.any((parameter) => dartName == parameter.type.type)) {
+ final translator = Translator.instance!;
+ if (dartName == translator.dartClassNameFor(returnType.type) ||
+ parameters.any(
+ (parameter) =>
+ dartName == translator.dartClassNameFor(parameter.type.type),
+ )) {
underscoreName();
}
return _name;
@@ -562,6 +566,11 @@
if (declaredInheritance == null) return;
final translator = Translator.instance!;
while (declaredInheritance != null) {
+ assert(
+ !translator.legacyWindowAliases.containsKey(declaredInheritance),
+ 'Unexpected [LegacyWindowAlias] "$declaredInheritance" in '
+ 'inheritance of "$name".',
+ );
if (translator.markTypeAsUsed(declaredInheritance)) {
inheritance = declaredInheritance;
break;
@@ -624,7 +633,7 @@
trustedType = split?.$2 {
final dartName = name.name;
final jsName = name.jsOverride.isEmpty ? dartName : name.jsOverride;
- this.name = dartName == type.type
+ this.name = dartName == Translator.instance!.dartClassNameFor(type.type)
? MemberName('${dartName}_', jsName)
: name;
}
diff --git a/js_interop_gen/lib/src/generate_bindings.dart b/js_interop_gen/lib/src/generate_bindings.dart
index 3c54936..f301aaa 100644
--- a/js_interop_gen/lib/src/generate_bindings.dart
+++ b/js_interop_gen/lib/src/generate_bindings.dart
@@ -4,6 +4,7 @@
import 'dart:convert';
import 'dart:js_interop';
+import 'dart:js_interop_unsafe';
import 'package:js_interop/js_interop.dart';
import 'package:path/path.dart' as p;
@@ -21,7 +22,16 @@
JSRecord<CSSEntries>? data,
]) async {
final cssStyleDeclarations = <String>{};
- for (final data in (data ?? await css.listAll().toDart).values) {
+ final allData = data ?? await css.listAll().toDart;
+ // `@webref/css` 7+ returns a single object of consolidated arrays, while
+ // `@webref/css` 6 (still pinned in `web_generator/package.json` until the
+ // spec roll) returns a record of per-spec entries.
+ // TODO(kevmoo): Drop the `< 7` fallback once `web_generator/package.json`
+ // rolls to `@webref/css` 8+.
+ final entries = (allData as JSObject).has('properties')
+ ? [allData as CSSEntries]
+ : allData.values;
+ for (final data in entries) {
final properties = data.properties;
if (properties != null) {
for (var j = 0; j < properties.length; j++) {
diff --git a/js_interop_gen/lib/src/js/webidl_api.dart b/js_interop_gen/lib/src/js/webidl_api.dart
index 6faa936..9a8e3d1 100644
--- a/js_interop_gen/lib/src/js/webidl_api.dart
+++ b/js_interop_gen/lib/src/js/webidl_api.dart
@@ -48,10 +48,27 @@
}
extension ExtendedAttributeRightHandSideExtension
- on ExtendedAttributeRightHandSide {
+ on ExtendedAttributeRightHandSide? {
String? get value {
- final jsObj = this as JSObject;
+ final jsObj = this as JSObject?;
+ if (jsObj == null) return null;
final val = jsObj.getProperty('value'.toJS);
return val.isA<JSString>() ? (val as JSString).toDart : null;
}
+
+ /// The identifier(s) on the right-hand side, whether a single identifier or
+ /// an identifier list such as `(A, B)`.
+ List<String> get identifiers {
+ final jsObj = this as JSObject?;
+ if (jsObj == null) return const [];
+ final val = jsObj.getProperty('value'.toJS);
+ if (val.isA<JSString>()) return [(val as JSString).toDart];
+ if (val.isA<JSArray>()) {
+ return [
+ for (final item in (val as JSArray<JSObject>).toDart)
+ item.getProperty<JSString>('value'.toJS).toDart,
+ ];
+ }
+ return const [];
+ }
}
diff --git a/js_interop_gen/lib/src/translator.dart b/js_interop_gen/lib/src/translator.dart
index a4e3742..6b28bdd 100644
--- a/js_interop_gen/lib/src/translator.dart
+++ b/js_interop_gen/lib/src/translator.dart
@@ -64,6 +64,29 @@
final interfacelike = node as idl.Interfacelike;
if (!node.partial) {
_addNamed<idl.Interfacelike>(node, interfaceList);
+ final name = interfacelike.name;
+ for (final extAttr in interfacelike.extAttrs.toDart) {
+ switch (extAttr.name) {
+ case 'LegacyWindowAlias':
+ // e.g. `[LegacyWindowAlias=SVGRect] interface DOMRect`: specs
+ // may still reference the alias name as a type.
+ for (final alias in extAttr.rhs.identifiers) {
+ translator._legacyWindowAliases[alias] = name;
+ }
+ case 'LegacyNamespace'
+ when type != 'namespace' && _dartCoreTypeNames.contains(name):
+ // Namespaced interfaces whose name would shadow a `dart:core`
+ // type (e.g. `WebAssembly.Exception`) are prefixed with their
+ // namespace.
+ final namespace = extAttr.rhs.value;
+ if (namespace != null) {
+ final dartName = '$namespace$name';
+ translator._idlToDartClassName[name] = dartName;
+ translator._dartToIdlClassName[dartName] = name;
+ translator._typeToLibrary[dartName] = this;
+ }
+ }
+ }
} else {
translator._typeToPartials
.putIfAbsent(interfacelike.name, () => [])
@@ -117,12 +140,28 @@
final _includes = <String, List<String>>{};
final _usedTypes = <idl.Node>{};
Map<String, idl.Node> get typeToDeclaration => _typeToDeclaration;
+
+ /// Maps `[LegacyWindowAlias]` names to the interface they alias.
+ final _legacyWindowAliases = <String, String>{};
+ Map<String, String> get legacyWindowAliases => _legacyWindowAliases;
+ final _idlToDartClassName = <String, String>{};
+ final _dartToIdlClassName = <String, String>{};
final _renamedClasses = <String, String>{};
final _currentDocImports = <String>{};
final _currentLibraryImports = <String>{};
Map<String, String> get renamedClasses => _renamedClasses;
+ /// Resolves WebIDL aliases (`WindowProxy` and `[LegacyWindowAlias]`) to their
+ /// canonical IDL declaration name.
+ String resolveIdlTypeName(String idlType) => switch (idlType) {
+ 'WindowProxy' => 'Window',
+ _ => _legacyWindowAliases[idlType] ?? idlType,
+ };
+
+ /// Returns the emitted Dart declaration name for a canonical IDL [name].
+ String dartClassNameFor(String name) => _idlToDartClassName[name] ?? name;
+
final Map<String, String> loadedRenameMap;
late String _currentlyTranslatingUrl;
@@ -315,7 +354,7 @@
if (uniqueNames.length <= 1) return [];
final formattedNames = uniqueNames.map((name) {
- final decl = _typeToDeclaration[name];
+ final decl = _typeToDeclaration[_dartToIdlClassName[name] ?? name];
if (decl != null && _usedTypes.contains(decl)) {
return '[$name]';
}
@@ -361,7 +400,7 @@
}
return;
}
- final name = (idlType.idlType as JSString).toDart;
+ final name = resolveIdlTypeName((idlType.idlType as JSString).toDart);
final library = _typeToLibrary[name];
if (library != null && library.url != _currentlyTranslatingUrl) {
_currentDocImports.add(library.url);
@@ -369,7 +408,6 @@
}
String? _mapIdlPrimitiveToDart(String idlType) {
- if (idlType == 'WindowProxy') return 'Window';
final alias = idlOrBuiltinToJsTypeAliases[idlType];
if (alias == 'JSObject' && idlType != 'object') return null;
if (alias == 'JSInteger' || alias == 'JSDouble') return 'JSNumber';
@@ -393,7 +431,7 @@
}
return genericName;
}
- final name = (idlType.idlType as JSString).toDart;
+ final name = resolveIdlTypeName((idlType.idlType as JSString).toDart);
final mapped = _mapIdlPrimitiveToDart(name);
if (mapped != null) {
@@ -402,10 +440,10 @@
final alias = idlOrBuiltinToJsTypeAliases[name];
if (alias == 'JSObject' && name != 'object') {
- return name;
+ return dartClassNameFor(name);
}
- return alias ?? name;
+ return alias ?? dartClassNameFor(name);
}
code.TypeDef _typedef(
@@ -491,6 +529,7 @@
dartType = jsTypeToDartPrimitiveAliases[dartType] ?? dartType;
if (dartType == 'void') nullable = false;
}
+ dartType = dartClassNameFor(dartType);
final typeArguments = <code.TypeReference>[];
if (typeParameter != null &&
@@ -1120,7 +1159,9 @@
// Namespaces have lowercase names. We also translate them to
// private classes, and make their first character uppercase in the process.
- final dartClassName = isNamespace ? '\$${capitalize(jsName)}' : jsName;
+ final dartClassName = isNamespace
+ ? '\$${capitalize(jsName)}'
+ : dartClassNameFor(jsName);
final interfaceStatus = browserCompatData.retrieveInterfaceFor(name);
@@ -1254,3 +1295,21 @@
return dartLibraries;
}
}
+
+/// `dart:core` type names that generated interfaces must not shadow, since
+/// `package:web` is commonly imported unprefixed.
+const _dartCoreTypeNames = {
+ 'BigInt',
+ 'Error',
+ 'Exception',
+ 'Function',
+ 'Iterable',
+ 'Iterator',
+ 'List',
+ 'Map',
+ 'Object',
+ 'Record',
+ 'Set',
+ 'Symbol',
+ 'Type',
+};
diff --git a/js_interop_gen/lib/src/type_aliases.dart b/js_interop_gen/lib/src/type_aliases.dart
index eaa5f33..54d5321 100644
--- a/js_interop_gen/lib/src/type_aliases.dart
+++ b/js_interop_gen/lib/src/type_aliases.dart
@@ -36,6 +36,8 @@
'sequence': 'JSArray',
'FrozenArray': 'JSArray',
'ObservableArray': 'JSArray',
+ // Wait for `JSAsyncIterable` before typing `async_sequence`.
+ 'async_sequence': 'JSObject',
// Number aliases. Like `JSUndefined`, `JSInteger` and `JSDouble` are special
// sentinels so that we can differentiate between `int` and `double` values
diff --git a/js_interop_gen/test/integration/idl/legacy_names_expected.dart b/js_interop_gen/test/integration/idl/legacy_names_expected.dart
new file mode 100644
index 0000000..6e42bb3
--- /dev/null
+++ b/js_interop_gen/test/integration/idl/legacy_names_expected.dart
@@ -0,0 +1,44 @@
+// Generated from Web IDL definitions.
+
+// ignore_for_file: constant_identifier_names, non_constant_identifier_names
+// ignore_for_file: unnecessary_ignore
+
+@JS()
+library;
+
+import 'dart:js_interop';
+
+/// Union of: [JSString], [WebAssemblyException]
+typedef ExceptionOrString = JSAny;
+
+/// Union of: [BoxTarget], [JSString]
+typedef BoxOrString = JSAny;
+
+@JS('WebAssembly.Exception')
+extension type WebAssemblyException._(JSObject _) implements JSObject {
+ external factory WebAssemblyException();
+}
+
+extension type SubException._(JSObject _)
+ implements WebAssemblyException, JSObject {}
+
+extension type ExceptionThrower._(JSObject _) implements JSObject {
+ external factory ExceptionThrower();
+
+ external WebAssemblyException make();
+ external JSPromise<WebAssemblyException> makeAsync();
+ external void take(WebAssemblyException e);
+ external void takeUnion(ExceptionOrString value);
+ external WebAssemblyException get last;
+ external set last(WebAssemblyException value);
+}
+
+extension type BoxTarget._(JSObject _) implements JSObject {
+ external factory BoxTarget();
+}
+
+extension type BoxReader._(JSObject _) implements JSObject {
+ external BoxTarget readBox();
+ external BoxTarget readAlt();
+ external void takeUnion(BoxOrString value);
+}
diff --git a/js_interop_gen/test/integration/idl/legacy_names_input.idl b/js_interop_gen/test/integration/idl/legacy_names_input.idl
new file mode 100644
index 0000000..e3a990b
--- /dev/null
+++ b/js_interop_gen/test/integration/idl/legacy_names_input.idl
@@ -0,0 +1,33 @@
+[LegacyNamespace=WebAssembly, Exposed=*]
+interface Exception {
+ constructor();
+};
+
+[Exposed=Window]
+interface SubException : Exception {};
+
+typedef (Exception or DOMString) ExceptionOrString;
+
+[Exposed=Window]
+interface ExceptionThrower {
+ constructor();
+ Exception make();
+ Promise<Exception> makeAsync();
+ undefined take(Exception e);
+ undefined takeUnion(ExceptionOrString value);
+ attribute Exception last;
+};
+
+[LegacyWindowAlias=(LegacyBox, LegacyBoxAlt), Exposed=Window]
+interface BoxTarget {
+ constructor();
+};
+
+typedef (LegacyBox or DOMString) BoxOrString;
+
+[Exposed=Window]
+interface BoxReader {
+ LegacyBox readBox();
+ LegacyBoxAlt readAlt();
+ undefined takeUnion(BoxOrString value);
+};
diff --git a/js_interop_gen/test/integration/idl_test.dart b/js_interop_gen/test/integration/idl_test.dart
index 8dc738b..71b0091 100644
--- a/js_interop_gen/test/integration/idl_test.dart
+++ b/js_interop_gen/test/integration/idl_test.dart
@@ -1,6 +1,7 @@
@TestOn('vm')
library;
+import 'dart:convert';
import 'dart:io';
import 'package:js_interop_gen/src/cli.dart';
@@ -96,5 +97,113 @@
.forEach((f) => f.deleteSync());
});
}
+
+ test('cross-file LegacyNamespace and LegacyWindowAlias imports', () async {
+ final wasmIdl = File(p.join(outputDir, 'wasm_defs.idl'));
+ final boxIdl = File(p.join(outputDir, 'box_defs.idl'));
+ final consumerIdl = File(p.join(outputDir, 'consumer.idl'));
+ final wasmDart = File(p.join(outputDir, 'wasm_defs.dart'));
+ final boxDart = File(p.join(outputDir, 'box_defs.dart'));
+ final consumerDartFile = File(p.join(outputDir, 'consumer.dart'));
+
+ addTearDown(() async {
+ for (final file in [
+ wasmIdl,
+ boxIdl,
+ consumerIdl,
+ wasmDart,
+ boxDart,
+ consumerDartFile,
+ ]) {
+ if (await file.exists()) await file.delete();
+ }
+ });
+
+ await wasmIdl.writeAsString('''
+[LegacyNamespace=WebAssembly, Exposed=*]
+interface Exception {
+ constructor();
+};
+''');
+ await boxIdl.writeAsString('''
+[LegacyWindowAlias=LegacyBox, Exposed=Window]
+interface BoxTarget {
+ constructor();
+};
+''');
+ await consumerIdl.writeAsString('''
+typedef (LegacyBox or DOMString) BoxOrString;
+
+[Exposed=Window]
+interface Consumer {
+ Exception make();
+ undefined takeBox(BoxOrString box);
+};
+''');
+
+ final dummyBcdPath = p.relative(
+ p.join(outputDir, 'dummy_bcd.json'),
+ from: bindingsGenPath,
+ );
+ await runNode([
+ 'main.mjs',
+ '--input=${p.relative(wasmIdl.path, from: bindingsGenPath)}',
+ '--input=${p.relative(boxIdl.path, from: bindingsGenPath)}',
+ '--input=${p.relative(consumerIdl.path, from: bindingsGenPath)}',
+ '--output=${p.relative(outputDir, from: bindingsGenPath)}',
+ '--idl',
+ '--bcd-json=$dummyBcdPath',
+ ], workingDirectory: bindingsGenPath);
+
+ final consumerDart = await consumerDartFile.readAsString();
+ expect(consumerDart, contains("/// @docImport 'box_defs.dart';"));
+ expect(consumerDart, contains('/// Union of: [BoxTarget], [JSString]'));
+ expect(consumerDart, contains("import 'wasm_defs.dart';"));
+ expect(consumerDart, contains('external WebAssemblyException make();'));
+ });
+
+ test('asserts on LegacyWindowAlias in inheritance', () async {
+ final invalidIdl = File(p.join(outputDir, 'invalid_alias_inherit.idl'));
+ final invalidDart = File(p.join(outputDir, 'invalid_alias_inherit.dart'));
+
+ addTearDown(() async {
+ for (final file in [invalidIdl, invalidDart]) {
+ if (await file.exists()) await file.delete();
+ }
+ });
+
+ await invalidIdl.writeAsString('''
+[LegacyWindowAlias=LegacyBox, Exposed=Window]
+interface BoxTarget {
+ constructor();
+};
+
+[Exposed=Window]
+interface SubBox : LegacyBox {};
+''');
+
+ final dummyBcdPath = p.relative(
+ p.join(outputDir, 'dummy_bcd.json'),
+ from: bindingsGenPath,
+ );
+ final proc = await runNodeWithResult([
+ 'main.mjs',
+ '--input=${p.relative(invalidIdl.path, from: bindingsGenPath)}',
+ '--output=${p.relative(outputDir, from: bindingsGenPath)}',
+ '--idl',
+ '--bcd-json=$dummyBcdPath',
+ ], workingDirectory: bindingsGenPath);
+
+ final stderrText = await proc.stderr.transform(utf8.decoder).join();
+ await proc.stdout.drain<void>();
+ expect(await proc.exitCode, isNonZero);
+ expect(
+ stderrText,
+ contains(
+ 'Unexpected [LegacyWindowAlias] "LegacyBox" in inheritance of '
+ '"SubBox".',
+ ),
+ );
+ });
});
}
diff --git a/web_generator/README.md b/web_generator/README.md
index a9de40b..199315f 100644
--- a/web_generator/README.md
+++ b/web_generator/README.md
@@ -22,8 +22,11 @@
dart bin/update_idl_bindings.dart --update
```
-or, manually edit `lib/src/package.json` to use specific IDL versions, and
-re-run `update_idl_bindings.dart`.
+which installs the latest `@webref/*` and `@mdn/browser-compat-data` releases
+(including new major versions) into this package's `package.json`.
+
+Alternatively, manually edit `package.json` to use specific versions, and re-run
+`update_idl_bindings.dart`.
### Public API golden
diff --git a/web_generator/bin/update_idl_bindings.dart b/web_generator/bin/update_idl_bindings.dart
index 7e6aa1a..edda04b 100644
--- a/web_generator/bin/update_idl_bindings.dart
+++ b/web_generator/bin/update_idl_bindings.dart
@@ -53,12 +53,23 @@
return;
}
- // Run `npm install` or `npm update` as needed.
+ // Run `npm install` or `npm update` as needed for the generator's own JS
+ // dependencies (webidl2, typescript).
final update = argResult['update'] as bool;
await runProc('npm', [
update ? 'update' : 'install',
], workingDirectory: bindingsGeneratorPath);
+ if (!isSnapshot) {
+ // The IDL, CSS, elements, and compat data live in this package's
+ // `package.json`. `npm update` never crosses a major version of a caret
+ // range, so `--update` installs the latest version of each instead.
+ await runProc('npm', [
+ 'install',
+ if (update) ...[for (final package in _dataPackages) '$package@latest'],
+ ], workingDirectory: p.fromUri(Platform.script.resolve('..')));
+ }
+
// Compute JS type supertypes for union calculation in translator.
await checkJsTypeSupertypes();
@@ -194,13 +205,10 @@
Map<String, dynamic>? _cachedPackageLockData;
String _packageLockVersion(String package) {
- final packageLockData = _cachedPackageLockData ??=
- jsonDecode(
- File(
- p.fromUri(Platform.script.resolve('../package-lock.json')),
- ).readAsStringSync(),
- )
- as Map<String, dynamic>;
+ final packageLockData = _cachedPackageLockData ??= jsonDecode(
+ File(p.fromUri(Platform.script.resolve('../package-lock.json')))
+ .readAsStringSync(),
+ ) as Map<String, dynamic>;
final packages = packageLockData['packages'] as Map<String, dynamic>;
final webRefIdl = packages['node_modules/$package'] as Map<String, dynamic>;
@@ -212,6 +220,13 @@
const _webRefElements = '@webref/elements';
const _webRefIdl = '@webref/idl';
+const _dataPackages = [
+ _browserCompatData,
+ _webRefCss,
+ _webRefElements,
+ _webRefIdl,
+];
+
final _thisScript = Uri.parse('bin/update_idl_bindings.dart');
final _scriptPOSIXPath = _thisScript.toFilePath(windows: false);
@@ -232,7 +247,11 @@
final _parser = ArgParser()
..addFlag('help', negatable: false, help: 'Show help information')
- ..addFlag('update', abbr: 'u', help: 'Update npm dependencies')
+ ..addFlag(
+ 'update',
+ abbr: 'u',
+ help: 'Update npm dependencies, including the latest IDL and compat data',
+ )
..addFlag('compile', defaultsTo: true)
..addOption(
'output',