| // Copyright (c) 2017, 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. |
| |
| /// Code for displaying the API as HTML. This is used both for generating a |
| /// full description of the API as a web page, and for generating doc comments |
| /// in generated code. |
| import 'dart:convert'; |
| |
| import 'package:analyzer_utilities/html_dom.dart' as dom; |
| import 'package:analyzer_utilities/html_generator.dart'; |
| import 'package:analyzer_utilities/tools.dart'; |
| |
| import 'api.dart'; |
| import 'from_html.dart'; |
| |
| /// Embedded stylesheet |
| final String stylesheet = ''' |
| body { |
| font-family: 'Roboto', sans-serif; |
| max-width: 800px; |
| margin: 0 auto; |
| padding: 0 16px; |
| font-size: 16px; |
| line-height: 1.5; |
| color: #111; |
| background-color: #fdfdfd; |
| font-weight: 300; |
| -webkit-font-smoothing: auto; |
| } |
| |
| h2, h3, h4, h5 { |
| margin-bottom: 0; |
| } |
| |
| h2.domain { |
| border-bottom: 1px solid rgb(200, 200, 200); |
| margin-bottom: 0.5em; |
| } |
| |
| h4 { |
| font-size: 18px; |
| } |
| |
| h5 { |
| font-size: 16px; |
| } |
| |
| p { |
| margin-top: 0; |
| } |
| |
| pre { |
| margin: 0; |
| font-family: 'Source Code Pro', monospace; |
| font-size: 15px; |
| } |
| |
| div.box { |
| background-color: rgb(240, 245, 240); |
| border-radius: 4px; |
| padding: 4px 12px; |
| margin: 16px 0; |
| } |
| |
| div.hangingIndent { |
| padding-left: 3em; |
| text-indent: -3em; |
| } |
| |
| dl dt { |
| font-weight: bold; |
| } |
| |
| dl dd { |
| margin-left: 16px; |
| } |
| |
| dt { |
| margin-top: 1em; |
| } |
| |
| dt.notification { |
| font-weight: bold; |
| } |
| |
| dt.refactoring { |
| font-weight: bold; |
| } |
| |
| dt.request { |
| font-weight: bold; |
| } |
| |
| dt.typeDefinition { |
| font-weight: bold; |
| } |
| |
| a { |
| text-decoration: none; |
| } |
| |
| a:focus, a:hover { |
| text-decoration: underline; |
| } |
| |
| .deprecated { |
| text-decoration: line-through; |
| } |
| |
| /* Styles for index */ |
| |
| .subindex ul { |
| padding-left: 0; |
| margin-left: 0; |
| |
| -webkit-margin-before: 0; |
| -webkit-margin-start: 0; |
| -webkit-padding-start: 0; |
| |
| list-style-type: none; |
| } |
| ''' |
| .trim(); |
| |
| final GeneratedFile target = |
| GeneratedFile('doc/api.html', (String pkgPath) async { |
| var visitor = ToHtmlVisitor(readApi(pkgPath)); |
| var document = dom.Document(); |
| for (var node in visitor.collectHtml(visitor.visitApi)) { |
| document.append(node); |
| } |
| return document.outerHtml; |
| }); |
| |
| String _toTitleCase(String str) { |
| if (str.isEmpty) return str; |
| return str.substring(0, 1).toUpperCase() + str.substring(1); |
| } |
| |
| /// Visitor that records the mapping from HTML elements to various kinds of API |
| /// nodes. |
| class ApiMappings extends HierarchicalApiVisitor { |
| Map<dom.Element, Domain> domains = <dom.Element, Domain>{}; |
| |
| ApiMappings(Api api) : super(api); |
| |
| @override |
| void visitDomain(Domain domain) { |
| domains[domain.html!] = domain; |
| } |
| } |
| |
| /// Helper methods for creating HTML elements. |
| abstract class HtmlMixin { |
| void anchor(String id, void Function() callback) { |
| element('a', {'name': id}, callback); |
| } |
| |
| void b(void Function() callback) => element('b', {}, callback); |
| void body(void Function() callback) => element('body', {}, callback); |
| void box(void Function() callback) { |
| element('div', {'class': 'box'}, callback); |
| } |
| |
| void br() => element('br', {}); |
| void dd(void Function() callback) => element('dd', {}, callback); |
| void dl(void Function() callback) => element('dl', {}, callback); |
| void dt(String cls, void Function() callback) => |
| element('dt', {'class': cls}, callback); |
| void element(String name, Map<String, String> attributes, |
| [void Function()? callback]); |
| void gray(void Function() callback) => |
| element('span', {'style': 'color:#999999'}, callback); |
| void h1(void Function() callback) => element('h1', {}, callback); |
| void h2(String? cls, void Function() callback) { |
| if (cls == null) { |
| return element('h2', {}, callback); |
| } |
| return element('h2', {'class': cls}, callback); |
| } |
| |
| void h3(void Function() callback) => element('h3', {}, callback); |
| void h4(void Function() callback) => element('h4', {}, callback); |
| void h5(void Function() callback) => element('h5', {}, callback); |
| void hangingIndent(void Function() callback) => |
| element('div', {'class': 'hangingIndent'}, callback); |
| void head(void Function() callback) => element('head', {}, callback); |
| void html(void Function() callback) => element('html', {}, callback); |
| void i(void Function() callback) => element('i', {}, callback); |
| void li(void Function() callback) => element('li', {}, callback); |
| void link(String id, void Function() callback, |
| [Map<String, String>? attributes]) { |
| attributes ??= {}; |
| attributes['href'] = '#$id'; |
| element('a', attributes, callback); |
| } |
| |
| void p(void Function() callback) => element('p', {}, callback); |
| void pre(void Function() callback) => element('pre', {}, callback); |
| void span(String cls, void Function() callback) => |
| element('span', {'class': cls}, callback); |
| void title(void Function() callback) => element('title', {}, callback); |
| void tt(void Function() callback) => element('tt', {}, callback); |
| void ul(void Function() callback) => element('ul', {}, callback); |
| } |
| |
| /// Visitor that generates HTML documentation of the API. |
| class ToHtmlVisitor extends HierarchicalApiVisitor |
| with HtmlMixin, HtmlGenerator { |
| /// Set of types defined in the API. |
| Set<String> definedTypes = <String>{}; |
| |
| /// Mappings from HTML elements to API nodes. |
| ApiMappings apiMappings; |
| |
| ToHtmlVisitor(Api api) |
| : apiMappings = ApiMappings(api), |
| super(api) { |
| apiMappings.visitApi(); |
| } |
| |
| /// Describe the payload of request, response, notification, refactoring |
| /// feedback, or refactoring options. |
| /// |
| /// If [force] is true, then a section is inserted even if the payload is |
| /// null. |
| void describePayload(TypeObject? subType, String name, {bool force = false}) { |
| if (force || subType != null) { |
| h4(() { |
| write(name); |
| }); |
| if (subType == null) { |
| p(() { |
| write('none'); |
| }); |
| } else { |
| visitTypeDecl(subType); |
| } |
| } |
| } |
| |
| void generateDomainIndex(Domain domain) { |
| h4(() { |
| write(domain.name); |
| write(' ('); |
| link('domain_${domain.name}', () => write('\u2191')); |
| write(')'); |
| }); |
| if (domain.requests.isNotEmpty) { |
| element('div', {'class': 'subindex'}, () { |
| generateRequestsIndex(domain.requests); |
| if (domain.notifications.isNotEmpty) { |
| generateNotificationsIndex(domain.notifications); |
| } |
| }); |
| } else if (domain.notifications.isNotEmpty) { |
| element('div', {'class': 'subindex'}, () { |
| generateNotificationsIndex(domain.notifications); |
| }); |
| } |
| } |
| |
| void generateDomainsHeader() { |
| h1(() { |
| write('Domains'); |
| }); |
| } |
| |
| void generateIndex() { |
| h3(() => write('Domains')); |
| for (var domain in api.domains) { |
| if (domain.experimental || |
| (domain.requests.isEmpty && domain.notifications.isEmpty)) { |
| continue; |
| } |
| generateDomainIndex(domain); |
| } |
| |
| generateTypesIndex(definedTypes); |
| generateRefactoringsIndex(api.refactorings!); |
| } |
| |
| void generateNotificationsIndex(Iterable<Notification> notifications) { |
| h5(() => write('Notifications')); |
| element('div', {'class': 'subindex'}, () { |
| element('ul', {}, () { |
| for (var notification in notifications) { |
| element( |
| 'li', |
| {}, |
| () => link('notification_${notification.longEvent}', |
| () => write(notification.event))); |
| } |
| }); |
| }); |
| } |
| |
| void generateRefactoringsIndex(Iterable<Refactoring> refactorings) { |
| h3(() { |
| write('Refactorings'); |
| write(' ('); |
| link('refactorings', () => write('\u2191')); |
| write(')'); |
| }); |
| // TODO: Individual refactorings are not yet hyperlinked. |
| element('div', {'class': 'subindex'}, () { |
| element('ul', {}, () { |
| for (var refactoring in refactorings) { |
| element( |
| 'li', |
| {}, |
| () => link('refactoring_${refactoring.kind}', |
| () => write(refactoring.kind))); |
| } |
| }); |
| }); |
| } |
| |
| void generateRequestsIndex(Iterable<Request> requests) { |
| h5(() => write('Requests')); |
| element('ul', {}, () { |
| for (var request in requests) { |
| if (!request.experimental) { |
| element( |
| 'li', |
| {}, |
| () => link('request_${request.longMethod}', |
| () => write(request.method))); |
| } |
| } |
| }); |
| } |
| |
| void generateTableOfContents() { |
| for (var domain in api.domains.where((domain) => !domain.experimental)) { |
| if (domain.experimental) continue; |
| |
| writeln(); |
| |
| p(() { |
| link('domain_${domain.name}', () { |
| write(_toTitleCase(domain.name)); |
| }); |
| }); |
| |
| ul(() { |
| for (var request in domain.requests) { |
| if (request.experimental) continue; |
| |
| li(() { |
| link('request_${request.longMethod}', () { |
| write(request.longMethod); |
| }, request.deprecated ? {'class': 'deprecated'} : null); |
| }); |
| writeln(); |
| } |
| }); |
| |
| writeln(); |
| } |
| } |
| |
| void generateTypesIndex(Set<String> types) { |
| h3(() { |
| write('Types'); |
| write(' ('); |
| link('types', () => write('\u2191')); |
| write(')'); |
| }); |
| var sortedTypes = types.toList(); |
| sortedTypes.sort(); |
| element('div', {'class': 'subindex'}, () { |
| element('ul', {}, () { |
| for (var type in sortedTypes) { |
| element('li', {}, () => link('type_$type', () => write(type))); |
| } |
| }); |
| }); |
| } |
| |
| void javadocParams(TypeObject typeObject) { |
| for (var field in typeObject.fields) { |
| hangingIndent(() { |
| write('@param ${field.name} '); |
| translateHtml(field.html, squashParagraphs: true); |
| }); |
| } |
| } |
| |
| /// Generate a description of [type] using [TypeVisitor]. |
| /// |
| /// If [shortDesc] is non-null, the output is prefixed with this string |
| /// and a colon. |
| /// |
| /// If [typeForBolding] is supplied, then fields in this type are shown in |
| /// boldface. |
| void showType(String? shortDesc, TypeDecl type, |
| [TypeObject? typeForBolding]) { |
| var fieldsToBold = <String>{}; |
| if (typeForBolding != null) { |
| for (var field in typeForBolding.fields) { |
| fieldsToBold.add(field.name); |
| } |
| } |
| pre(() { |
| if (shortDesc != null) { |
| write('$shortDesc: '); |
| } |
| var typeVisitor = TypeVisitor(api, fieldsToBold: fieldsToBold); |
| addAll(typeVisitor.collectHtml(() { |
| typeVisitor.visitTypeDecl(type); |
| })); |
| }); |
| } |
| |
| /// Copy the contents of the given HTML element, translating the special |
| /// elements that define the API appropriately. |
| void translateHtml(dom.Element? html, {bool squashParagraphs = false}) { |
| if (html == null) { |
| return; |
| } |
| for (var node in html.nodes) { |
| if (node is dom.Element) { |
| if (squashParagraphs && node.localName == 'p') { |
| translateHtml(node, squashParagraphs: squashParagraphs); |
| continue; |
| } |
| switch (node.localName) { |
| case 'domains': |
| generateDomainsHeader(); |
| break; |
| case 'domain': |
| visitDomain(apiMappings.domains[node]!); |
| break; |
| case 'head': |
| head(() { |
| translateHtml(node, squashParagraphs: squashParagraphs); |
| element('link', { |
| 'rel': 'stylesheet', |
| 'href': |
| 'https://fonts.googleapis.com/css?family=Source+Code+Pro|Roboto:500,400italic,300,400', |
| 'type': 'text/css' |
| }); |
| element('style', {}, () { |
| writeln(stylesheet); |
| }); |
| }); |
| break; |
| case 'refactorings': |
| visitRefactorings(api.refactorings); |
| break; |
| case 'types': |
| visitTypes(api.types); |
| break; |
| case 'version': |
| translateHtml(node, squashParagraphs: squashParagraphs); |
| break; |
| case 'toc': |
| generateTableOfContents(); |
| break; |
| case 'index': |
| generateIndex(); |
| break; |
| default: |
| if (!ApiReader.specialElements.contains(node.localName)) { |
| element(node.localName!, node.attributes, () { |
| translateHtml(node, squashParagraphs: squashParagraphs); |
| }); |
| } |
| } |
| } else if (node is dom.Text) { |
| var text = node.text; |
| write(text); |
| } |
| } |
| } |
| |
| @override |
| void visitApi() { |
| var apiTypes = api.types.where((TypeDefinition td) => !td.experimental); |
| definedTypes = apiTypes.map((TypeDefinition td) => td.name).toSet(); |
| |
| html(() { |
| translateHtml(api.html); |
| }); |
| } |
| |
| @override |
| void visitDomain(Domain domain) { |
| if (domain.experimental) { |
| return; |
| } |
| h2('domain', () { |
| anchor('domain_${domain.name}', () { |
| write('${domain.name} domain'); |
| }); |
| }); |
| translateHtml(domain.html); |
| if (domain.requests.isNotEmpty) { |
| h3(() { |
| write('Requests'); |
| }); |
| dl(() { |
| domain.requests.forEach(visitRequest); |
| }); |
| } |
| if (domain.notifications.isNotEmpty) { |
| h3(() { |
| write('Notifications'); |
| }); |
| dl(() { |
| domain.notifications.forEach(visitNotification); |
| }); |
| } |
| } |
| |
| @override |
| void visitNotification(Notification notification) { |
| dt('notification', () { |
| anchor('notification_${notification.longEvent}', () { |
| write(notification.longEvent); |
| }); |
| }); |
| dd(() { |
| box(() { |
| showType( |
| 'notification', notification.notificationType, notification.params); |
| }); |
| translateHtml(notification.html); |
| describePayload(notification.params, 'parameters:'); |
| }); |
| } |
| |
| @override |
| void visitRefactoring(Refactoring refactoring) { |
| dt('refactoring', () { |
| write(refactoring.kind); |
| }); |
| dd(() { |
| translateHtml(refactoring.html); |
| describePayload(refactoring.feedback, 'Feedback:', force: true); |
| describePayload(refactoring.options, 'Options:', force: true); |
| }); |
| } |
| |
| @override |
| void visitRefactorings(Refactorings? refactorings) { |
| translateHtml(refactorings?.html); |
| dl(() { |
| super.visitRefactorings(refactorings); |
| }); |
| } |
| |
| @override |
| void visitRequest(Request request) { |
| if (request.experimental) { |
| return; |
| } |
| dt(request.deprecated ? 'request deprecated' : 'request', () { |
| anchor('request_${request.longMethod}', () { |
| write(request.longMethod); |
| }); |
| }); |
| dd(() { |
| box(() { |
| showType('request', request.requestType, request.params); |
| br(); |
| showType('response', request.responseType, request.result); |
| }); |
| translateHtml(request.html); |
| describePayload(request.params, 'parameters:'); |
| describePayload(request.result, 'returns:'); |
| }); |
| } |
| |
| @override |
| void visitTypeDefinition(TypeDefinition typeDefinition) { |
| if (typeDefinition.experimental) { |
| return; |
| } |
| dt( |
| typeDefinition.deprecated |
| ? 'typeDefinition deprecated' |
| : 'typeDefinition', () { |
| anchor('type_${typeDefinition.name}', () { |
| write('${typeDefinition.name}: '); |
| var typeVisitor = TypeVisitor(api, short: true); |
| addAll(typeVisitor.collectHtml(() { |
| typeVisitor.visitTypeDecl(typeDefinition.type); |
| })); |
| }); |
| }); |
| dd(() { |
| translateHtml(typeDefinition.html); |
| visitTypeDecl(typeDefinition.type); |
| }); |
| } |
| |
| @override |
| void visitTypeEnum(TypeEnum typeEnum) { |
| dl(() { |
| super.visitTypeEnum(typeEnum); |
| }); |
| } |
| |
| @override |
| void visitTypeEnumValue(TypeEnumValue typeEnumValue) { |
| var isDocumented = false; |
| for (var node in typeEnumValue.html?.nodes ?? []) { |
| if ((node is dom.Element && node.localName != 'code') || |
| (node is dom.Text && node.text.trim().isNotEmpty)) { |
| isDocumented = true; |
| break; |
| } |
| } |
| dt(typeEnumValue.deprecated ? 'value deprecated' : 'value', () { |
| write(typeEnumValue.value); |
| }); |
| if (isDocumented) { |
| dd(() { |
| translateHtml(typeEnumValue.html); |
| }); |
| } |
| } |
| |
| @override |
| void visitTypeList(TypeList typeList) { |
| visitTypeDecl(typeList.itemType); |
| } |
| |
| @override |
| void visitTypeMap(TypeMap typeMap) { |
| visitTypeDecl(typeMap.valueType); |
| } |
| |
| @override |
| void visitTypeObject(TypeObject typeObject) { |
| dl(() { |
| super.visitTypeObject(typeObject); |
| }); |
| } |
| |
| @override |
| void visitTypeObjectField(TypeObjectField typeObjectField) { |
| if (typeObjectField.experimental) { |
| return; |
| } |
| dt('field', () { |
| b(() { |
| if (typeObjectField.deprecated) { |
| span('deprecated', () { |
| write(typeObjectField.name); |
| }); |
| } else { |
| write(typeObjectField.name); |
| } |
| if (typeObjectField.value != null) { |
| write(' = ${json.encode(typeObjectField.value)}'); |
| } else { |
| write(': '); |
| var typeVisitor = TypeVisitor(api, short: true); |
| addAll(typeVisitor.collectHtml(() { |
| typeVisitor.visitTypeDecl(typeObjectField.type); |
| })); |
| if (typeObjectField.optional) { |
| gray(() => write(' (optional)')); |
| } |
| } |
| }); |
| }); |
| dd(() { |
| translateHtml(typeObjectField.html); |
| }); |
| } |
| |
| @override |
| void visitTypeReference(TypeReference typeReference) {} |
| |
| @override |
| void visitTypes(Types types) { |
| translateHtml(types.html); |
| dl(() { |
| var sortedTypes = types.toList(); |
| sortedTypes.sort((TypeDefinition first, TypeDefinition second) => |
| first.name.compareTo(second.name)); |
| sortedTypes.forEach(visitTypeDefinition); |
| }); |
| } |
| } |
| |
| /// Visitor that generates a compact representation of a type, such as: |
| /// |
| /// { |
| /// "id": String |
| /// "error": optional Error |
| /// "result": { |
| /// "version": String |
| /// } |
| /// } |
| class TypeVisitor extends HierarchicalApiVisitor |
| with HtmlMixin, HtmlCodeGenerator { |
| /// Set of fields which should be shown in boldface, or null if no field |
| /// should be shown in boldface. |
| final Set<String>? fieldsToBold; |
| |
| /// True if a short description should be generated. In a short description, |
| /// objects are shown as simply "object", and enums are shown as "String". |
| final bool short; |
| |
| TypeVisitor(Api api, {this.fieldsToBold, this.short = false}) : super(api); |
| |
| @override |
| void visitTypeEnum(TypeEnum typeEnum) { |
| if (short) { |
| write('String'); |
| return; |
| } |
| writeln('enum {'); |
| indent(() { |
| for (var value in typeEnum.values) { |
| writeln(value.value); |
| } |
| }); |
| write('}'); |
| } |
| |
| @override |
| void visitTypeList(TypeList typeList) { |
| write('List<'); |
| visitTypeDecl(typeList.itemType); |
| write('>'); |
| } |
| |
| @override |
| void visitTypeMap(TypeMap typeMap) { |
| write('Map<'); |
| visitTypeDecl(typeMap.keyType); |
| write(', '); |
| visitTypeDecl(typeMap.valueType); |
| write('>'); |
| } |
| |
| @override |
| void visitTypeObject(TypeObject typeObject) { |
| if (short) { |
| write('object'); |
| return; |
| } |
| writeln('{'); |
| indent(() { |
| for (var field in typeObject.fields) { |
| if (field.experimental) continue; |
| write('"'); |
| final fieldsToBold = this.fieldsToBold; |
| if (fieldsToBold != null && fieldsToBold.contains(field.name)) { |
| b(() { |
| write(field.name); |
| }); |
| } else { |
| write(field.name); |
| } |
| write('": '); |
| if (field.value != null) { |
| write(json.encode(field.value)); |
| } else { |
| if (field.optional) { |
| gray(() { |
| write('optional'); |
| }); |
| write(' '); |
| } |
| visitTypeDecl(field.type); |
| } |
| writeln(); |
| } |
| }); |
| write('}'); |
| } |
| |
| @override |
| void visitTypeReference(TypeReference typeReference) { |
| var displayName = typeReference.typeName; |
| if (api.types.containsKey(typeReference.typeName)) { |
| link('type_${typeReference.typeName}', () { |
| write(displayName); |
| }); |
| } else { |
| write(displayName); |
| } |
| } |
| |
| @override |
| void visitTypeUnion(TypeUnion typeUnion) { |
| var verticalBarNeeded = false; |
| for (var choice in typeUnion.choices) { |
| if (verticalBarNeeded) { |
| write(' | '); |
| } |
| visitTypeDecl(choice); |
| verticalBarNeeded = true; |
| } |
| } |
| } |