/// Generates lib/data/iso_639_data.dart from the curated offline ISO 639 catalog. /// /// Usage: /// dart run scripts/generate_iso_639_data.dart [input.json] [output.dart] library; import 'dart:convert'; import 'dart:io'; const defaultIso639Input = 'scripts/data/iso_639_codes.json'; const defaultIso639Output = 'lib/data/iso_639_data.dart'; final class Iso639CatalogEntry { const Iso639CatalogEntry({ required this.primary, required this.terminology, required this.bibliographic, required this.name, }); final String primary; final String terminology; final String? bibliographic; final String name; } final class Iso639Catalog { const Iso639Catalog({required this.entries}); final List entries; } typedef AtomicFileWriter = Future Function(String path, String contents); Iso639Catalog parseIso639Catalog(String source) { final Object? decoded; try { decoded = jsonDecode(source); } on FormatException catch (error) { throw FormatException('Invalid ISO 639 catalog JSON: ${error.message}'); } final root = _expectMap(decoded, 'catalog'); if (root['schemaVersion'] != 1) { throw const FormatException('ISO 639 catalog schemaVersion must be 1'); } final languages = _expectMap(root['languages'], 'languages'); if (languages.isEmpty) { throw const FormatException('ISO 639 catalog languages must not be empty'); } final entries = []; final allCodes = {}; String? previousPrimary; for (final catalogEntry in languages.entries) { final key = catalogEntry.key; if (!_isPrimaryCode(key)) { throw FormatException('Language key must be exactly two lowercase letters: $key'); } if (previousPrimary != null && key.compareTo(previousPrimary) <= 0) { throw FormatException('Language keys must be in strictly increasing order: $key'); } previousPrimary = key; final rawEntry = _expectMap(catalogEntry.value, 'languages.$key'); final primary = _expectString(rawEntry['primary'], 'languages.$key.primary'); if (primary != key) { throw FormatException('Language key $key does not match primary code $primary'); } if (!_isPrimaryCode(primary)) { throw FormatException('languages.$key.primary must be exactly two lowercase letters'); } if (!allCodes.add(primary)) { throw FormatException('Duplicate ISO 639 code: $primary'); } final terminology = _expectString(rawEntry['terminology'], 'languages.$key.terminology'); if (!_isAliasCode(terminology)) { throw FormatException('languages.$key.terminology must be exactly three lowercase letters'); } if (!allCodes.add(terminology)) { throw FormatException('Duplicate ISO 639 code: $terminology'); } final rawBibliographic = rawEntry['bibliographic']; final String? bibliographic; if (rawBibliographic == null) { bibliographic = null; } else { bibliographic = _expectString(rawBibliographic, 'languages.$key.bibliographic'); if (!_isAliasCode(bibliographic)) { throw FormatException('languages.$key.bibliographic must be null or exactly three lowercase letters'); } if (!allCodes.add(bibliographic)) { throw FormatException('Duplicate ISO 639 code: $bibliographic'); } } final name = _expectNonemptyString(rawEntry['name'], 'languages.$key.name'); entries.add( Iso639CatalogEntry(primary: primary, terminology: terminology, bibliographic: bibliographic, name: name), ); } return Iso639Catalog(entries: List.unmodifiable(entries)); } String renderIso639Data(Iso639Catalog catalog) { final output = StringBuffer() ..writeln( '// Generated by dart run scripts/generate_iso_639_data.dart from ' 'scripts/data/iso_639_codes.json; do not edit by hand.', ) ..writeln() ..writeln('class LanguageEntry {') ..writeln(' final String code1;') ..writeln(' final String code2;') ..writeln(' final String? code2B;') ..writeln(' final String name;') ..writeln(' const LanguageEntry(this.code1, this.code2, this.code2B, this.name);') ..writeln('}') ..writeln() ..writeln('const languageEntries = {'); for (final entry in catalog.entries) { final bibliographic = entry.bibliographic == null ? 'null' : _dartString(entry.bibliographic!); output.writeln( ' ${_dartString(entry.primary)}: LanguageEntry(' '${_dartString(entry.primary)}, ${_dartString(entry.terminology)}, $bibliographic, ${_dartString(entry.name)}),', ); } output ..writeln('};') ..writeln() ..writeln('const code2ToCode1 = {'); for (final entry in catalog.entries) { output.writeln(' ${_dartString(entry.terminology)}: ${_dartString(entry.primary)},'); } output ..writeln('};') ..writeln() ..writeln('const code2BToCode1 = {'); for (final entry in catalog.entries) { final bibliographic = entry.bibliographic; if (bibliographic != null) { output.writeln(' ${_dartString(bibliographic)}: ${_dartString(entry.primary)},'); } } output.writeln('};'); return output.toString(); } Future writeFileAtomically(String path, String contents) async { final output = File(path); await output.parent.create(recursive: true); final temporary = File('$path.tmp.$pid.${DateTime.now().microsecondsSinceEpoch}'); try { await temporary.writeAsString(contents, flush: true); await temporary.rename(path); } finally { if (await temporary.exists()) { await temporary.delete(); } } } Future generateIso639Data( String inputPath, String outputPath, { AtomicFileWriter writer = writeFileAtomically, }) async { final source = await File(inputPath).readAsString(); final catalog = parseIso639Catalog(source); final rendered = renderIso639Data(catalog); await writer(outputPath, rendered); } Future main(List arguments) async { if (arguments.length > 2) { stderr.writeln('Usage: dart run scripts/generate_iso_639_data.dart [input.json] [output.dart]'); exitCode = 64; return; } final inputPath = arguments.isEmpty ? defaultIso639Input : arguments[0]; final outputPath = arguments.length < 2 ? defaultIso639Output : arguments[1]; await generateIso639Data(inputPath, outputPath); } Map _expectMap(Object? value, String path) { if (value is! Map) { throw FormatException('$path must be a JSON object'); } return value; } String _expectString(Object? value, String path) { if (value is! String) { throw FormatException('$path must be a string'); } return value; } String _expectNonemptyString(Object? value, String path) { final string = _expectString(value, path); if (string.trim().isEmpty) { throw FormatException('$path must not be empty'); } return string; } bool _isPrimaryCode(String code) => RegExp(r'^[a-z]{2}$').hasMatch(code); bool _isAliasCode(String code) => RegExp(r'^[a-z]{3}$').hasMatch(code); String _dartString(String value) { if (value.contains("'")) { final escaped = value .replaceAll(r'\', r'\\') .replaceAll('"', r'\"') .replaceAll(r'$', r'\$') .replaceAll('\r', r'\r') .replaceAll('\n', r'\n') .replaceAll('\t', r'\t'); return '"$escaped"'; } final escaped = value .replaceAll(r'\', r'\\') .replaceAll("'", r"\'") .replaceAll(r'$', r'\$') .replaceAll('\r', r'\r') .replaceAll('\n', r'\n') .replaceAll('\t', r'\t'); return "'$escaped'"; }