blob: de78eddd6423e15e69ff32fdc6de0cdcedc8b3b3 [file] [log] [blame]
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
const mdnUrl = 'https://developer.mozilla.org/en-US/docs/Web';
const gitUrl = 'https://github.com/mdn/content.git';
Future<void> main(List<String> args) async {
final offline = args.length == 1 && args.first == '--offline';
// clone the repo
final repoPath =
p.fromUri(Platform.script.resolve('../.dart_tool/mdn_content'));
final repoDir = Directory(repoPath);
if (!repoDir.existsSync()) {
await _run(
'git',
[
'clone',
'--depth=1',
gitUrl,
p.basename(repoDir.path),
],
cwd: repoDir.parent,
);
} else {
if (!offline) {
await _run('git', ['pull'], cwd: repoDir);
}
}
print('');
final interfaces = <InterfaceInfo>[];
final apiDir =
Directory(p.join(repoDir.path, 'files', 'en-us', 'web', 'api'));
for (final dir in apiDir.listSync().whereType<Directory>()) {
final interfaceIndex = File(p.join(dir.path, 'index.md'));
if (!interfaceIndex.existsSync()) continue;
final docs = interfaceIndex.readAsStringSync();
if (!docs.contains('page-type: web-api-interface')) {
continue;
}
final indexFileContent = interfaceIndex.readAsStringSync();
final name = findTitle(indexFileContent) ?? p.basename(dir.path);
final info = InterfaceInfo(
name: name,
docs: convertMdnToMarkdown(interfaceIndex.readAsStringSync()),
);
interfaces.add(info);
for (final child in dir.listSync().whereType<Directory>()) {
final propertyIndex = File(p.join(child.path, 'index.md'));
if (!propertyIndex.existsSync()) continue;
final property = Property(
name: p.basename(child.path),
docs: convertMdnToMarkdown(propertyIndex.readAsStringSync()),
);
if (property.name != info.name) {
info.properties.add(property);
}
}
info.properties.sort();
}
interfaces.sort();
print('${interfaces.length} items read from $gitUrl.');
const encoder = JsonEncoder.withIndent(' ');
final filePath =
p.fromUri(Platform.script.resolve('../../third_party/mdn/mdn.json'));
final file = File(filePath);
final json = {
'__meta__': {
'source': '[MDN Web Docs]($mdnUrl)',
'license':
'[CC-BY-SA 2.5](https://creativecommons.org/licenses/by-sa/2.5/)',
},
for (var i in interfaces) i.name: i.asJson,
};
file.writeAsStringSync('${encoder.convert(json)}\n');
print('');
print('Wrote ${file.lengthSync()} bytes to ${file.path}.');
}
class InterfaceInfo implements Comparable<InterfaceInfo> {
final String name;
final String docs;
final List<Property> properties = [];
InterfaceInfo({
required this.name,
required this.docs,
});
Map<String, dynamic> get asJson => {
'docs': docs,
if (properties.isNotEmpty)
'properties': {for (var p in properties) p.name: p.docs},
};
@override
int compareTo(InterfaceInfo other) => name.compareTo(other.name);
}
class Property implements Comparable<Property> {
final String name;
final String docs;
Property({required this.name, required this.docs});
@override
int compareTo(Property other) => name.compareTo(other.name);
}
final RegExp _xrefRegex =
RegExp(r'''{{\s*(\w+)\(([\w\., \n/\*"'\(\)]+)\)\s*}}''');
final RegExp _mustacheRegex = RegExp(r'''{{\s*([\S ]+?)\s*}}''');
String? findTitle(String content) {
// Look for 'title: AbortController'.
for (var line in content.split('\n')) {
line = line.trim();
// early exit
if (line.isEmpty) return null;
if (line.contains(':')) {
final index = line.indexOf(':');
final key = line.substring(0, index).trim();
if (key == 'title') {
final value = line.substring(index + 1).trim();
// Work around 'title: Foo (Bar)'.
return value.split(' ').first;
}
}
}
return null;
}
String convertMdnToMarkdown(String content) {
var lines = content.split('\n');
// remove the front matter
if (lines.first.startsWith('---')) {
lines.removeUntil((line) => line == '---');
lines.removeUntil((line) => line == '---');
}
// remove everything after the first section
// TODO: handle cases where the first line is a section header
final index = lines.indexWhere((line) => line.startsWith('## '));
if (index != -1) {
lines = lines.sublist(0, index);
}
// remove leading and trailing blank lines
lines.removeWhile((line) => line.isEmpty);
while (lines.isNotEmpty && lines.last.isEmpty) {
lines.removeLast();
}
// Rewrite relative link references:
// "[WebGL API](/en-US/docs/Web/API/WebGL_API)"
final linkRefRegex = RegExp(r'\[([^\]]+)\]\(([\w\/-]+)\)');
lines = lines
.map((line) => line.replaceAllMapped(linkRefRegex, (match) {
final ref = match.group(1)!;
final link = match.group(2)!;
if (link.startsWith('/en-US/')) {
// prefix with 'https://developer.mozilla.org'
return '[$ref](https://developer.mozilla.org$link)';
} else {
return match.group(0)!;
}
}))
.toList();
var text = lines.join('\n');
// Convert {{jsxref("Promise")}} to code references and
// {{domxref("BluetoothRemote")}} to symbol references.
text = text.replaceAllMapped(_xrefRegex, (match) {
final type = match.group(1)!.toLowerCase();
if (type == 'apiref' || type == 'glossary' || type == 'svgelement') {
return '';
}
if (type == 'defaultapisidebar') {
return '';
}
var content = match.group(2)!;
if (type == 'availableinworkers') {
final name = match.group(1)!;
return '@$name($content)';
} else if (type == 'jsxref' ||
type == 'htmlelement' ||
type == 'svgattr' ||
type == 'cssxref') {
content = _stripQuotes(content.split(',').last);
return '`$content`';
} else if (type == 'domxref') {
content = content.split(',').first.trim();
content = _stripQuotes(content);
if (content.endsWith('()')) {
content = content.substring(0, content.length - 2);
}
// Rewrite [FontFace/status] references to [FontFace.status] ones.
if (content.contains('/')) {
content = content.replaceAll('/', '.');
}
// TODO: rewrite FileReader.loadend_event => FileReader.onloadend
return '[$content]';
} else {
content = _stripQuotes(content);
return '`$content`';
}
});
// Remove additional mustache-like directives ({{InheritanceDiagram}}, ...).
text = text.replaceAllMapped(_mustacheRegex, (match) => '');
// Replace multiple blank lines by 2 blank lines.
text = text.replaceAll(RegExp('\n\n\n+'), '\n\n');
return text.trim();
}
String _stripQuotes(String value) {
value = value.trim();
if (value.startsWith("'") && value.endsWith("'")) {
return value.substring(1, value.length - 1);
} else if (value.startsWith('"') && value.endsWith('"')) {
return value.substring(1, value.length - 1);
}
return value;
}
extension ListExtension on List<String> {
void removeUntil(bool Function(String) fn) {
while (true) {
if (fn(first)) {
removeAt(0);
return;
} else {
removeAt(0);
}
}
}
void removeWhile(bool Function(String) fn) {
if (isEmpty) return;
while (!isEmpty && fn(first)) {
removeAt(0);
}
}
}
Future<void> _run(
String command,
List<String> args, {
required Directory cwd,
}) async {
print('$command ${args.join(' ')} [${cwd.path}]');
final result = await Process.start(
command,
args,
workingDirectory: cwd.path,
mode: ProcessStartMode.inheritStdio,
);
final code = await result.exitCode;
if (code != 0) {
throw Exception('process exited with code $code');
}
}