| // Copyright (c) 2015, 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. | |
| /// Helper for creating HTML visualization of the source map information | |
| /// generated by a [SourceMapProcessor]. | |
| library sourcemap.html.helper; | |
| import 'dart:convert'; | |
| import 'dart:math' as Math; | |
| import 'package:compiler/src/io/source_file.dart'; | |
| import 'package:compiler/src/io/source_information.dart'; | |
| import 'package:compiler/src/js/js.dart' as js; | |
| import 'colors.dart'; | |
| import 'sourcemap_helper.dart'; | |
| import 'sourcemap_html_templates.dart'; | |
| import 'html_parts.dart'; | |
| /// Truncate [input] to [length], adding '...' if truncated. | |
| String truncate(String input, int length) { | |
| if (input.length > length) { | |
| return '${input.substring(0, length - 3)}...'; | |
| } | |
| return input; | |
| } | |
| const int HUE_COUNT = 24; | |
| /// Returns the [index]th color for visualization. | |
| HSV toColor(int index) { | |
| double h = 360.0 * (index % HUE_COUNT) / HUE_COUNT; | |
| double v = 1.0; | |
| double s = 0.5; | |
| return HSV(h, s, v); | |
| } | |
| /// Return the CSS color value for the [index]th color. | |
| String toColorCss(int index) { | |
| return toColor(index).toCss; | |
| } | |
| /// Return the CSS color value for the [index]th span. | |
| String toPattern(int index) { | |
| /// Use gradient on spans to visually identify consecutive spans mapped to the | |
| /// same source location. | |
| HSV startColor = toColor(index); | |
| HSV endColor = HSV(startColor.h, startColor.s + 0.4, startColor.v - 0.2); | |
| return 'linear-gradient(to right, ${startColor.toCss}, ${endColor.toCss})'; | |
| } | |
| /// Return the html for the [index] line number. If [width] is provided, shorter | |
| /// line numbers will be prefixed with spaces to match the width. | |
| String lineNumber( | |
| int index, { | |
| int? width, | |
| bool useNbsp = false, | |
| String? className, | |
| }) { | |
| if (className == null) { | |
| className = 'lineNumber'; | |
| } | |
| String text = '${index + 1}'; | |
| String padding = useNbsp ? ' ' : ' '; | |
| if (width != null && text.length < width) { | |
| text = (padding * (width - text.length)) + text; | |
| } | |
| return '<span class="$className">$text$padding</span>'; | |
| } | |
| /// Return the html escaped [text]. | |
| String escape(String text) { | |
| return const HtmlEscape().convert(text); | |
| } | |
| /// Information needed to generate HTML for a single [SourceMapInfo]. | |
| class SourceMapHtmlInfo { | |
| final SourceMapInfo sourceMapInfo; | |
| final CodeProcessor codeProcessor; | |
| final SourceLocationCollection sourceLocationCollection; | |
| SourceMapHtmlInfo( | |
| this.sourceMapInfo, | |
| this.codeProcessor, | |
| this.sourceLocationCollection, | |
| ); | |
| @override | |
| String toString() { | |
| return sourceMapInfo.toString(); | |
| } | |
| } | |
| /// A collection of source locations. | |
| /// | |
| /// Used to index source locations for visualization and linking. | |
| class SourceLocationCollection { | |
| List<SourceLocation> sourceLocations = []; | |
| Map<SourceLocation, int> sourceLocationIndexMap; | |
| SourceLocationCollection([SourceLocationCollection? parent]) | |
| : sourceLocationIndexMap = parent == null | |
| ? {} | |
| : parent.sourceLocationIndexMap; | |
| int registerSourceLocation(SourceLocation sourceLocation) { | |
| return sourceLocationIndexMap.putIfAbsent(sourceLocation, () { | |
| sourceLocations.add(sourceLocation); | |
| return sourceLocationIndexMap.length; | |
| }); | |
| } | |
| int getIndex(SourceLocation sourceLocation) { | |
| return sourceLocationIndexMap[sourceLocation]!; | |
| } | |
| } | |
| abstract class CssColorScheme { | |
| String singleLocationToCssColor(int id); | |
| String multiLocationToCssColor(List<int> ids); | |
| bool get showLocationAsSpan; | |
| } | |
| class PatternCssColorScheme implements CssColorScheme { | |
| const PatternCssColorScheme(); | |
| @override | |
| bool get showLocationAsSpan => true; | |
| @override | |
| String singleLocationToCssColor(int index) { | |
| return "background:${toPattern(index)};"; | |
| } | |
| @override | |
| String multiLocationToCssColor(List<int> indices) { | |
| StringBuffer sb = StringBuffer(); | |
| double delta = 100.0 / (indices.length); | |
| double position = 0.0; | |
| void addColor(String color) { | |
| sb.write(', ${color} ${position.toInt()}%'); | |
| position += delta; | |
| sb.write(', ${color} ${position.toInt()}%'); | |
| } | |
| for (int index in indices) { | |
| addColor('${toColorCss(index)}'); | |
| } | |
| return 'background: linear-gradient(to right${sb}); ' | |
| 'background-size: 10px 10px;'; | |
| } | |
| } | |
| class SingleColorScheme implements CssColorScheme { | |
| const SingleColorScheme(); | |
| @override | |
| bool get showLocationAsSpan => false; | |
| @override | |
| String singleLocationToCssColor(int index) { | |
| return "background:${toColorCss(index)};"; | |
| } | |
| @override | |
| String multiLocationToCssColor(List<int> indices) { | |
| StringBuffer sb = StringBuffer(); | |
| double delta = 100.0 / (indices.length); | |
| double position = 0.0; | |
| void addColor(String color) { | |
| sb.write(', ${color} ${position.toInt()}%'); | |
| position += delta; | |
| sb.write(', ${color} ${position.toInt()}%'); | |
| } | |
| for (int index in indices) { | |
| addColor('${toColorCss(index)}'); | |
| } | |
| return 'background: linear-gradient(to bottom${sb}); ' | |
| 'background-size: 10px 3px;'; | |
| } | |
| } | |
| /// Processor that computes the HTML representation of a block of JavaScript | |
| /// code and collects the source locations mapped in the code. | |
| class CodeProcessor { | |
| int lineIndex = 0; | |
| final String name; | |
| int currentJsSourceOffset = 0; | |
| final SourceLocationCollection collection; | |
| final Map<int, List<SourceLocation>> codeLocations = {}; | |
| final CssColorScheme colorScheme; | |
| CodeProcessor( | |
| this.name, | |
| this.collection, { | |
| this.colorScheme = const PatternCssColorScheme(), | |
| }); | |
| void addSourceLocation(int targetOffset, SourceLocation sourceLocation) { | |
| codeLocations.putIfAbsent(targetOffset, () => []).add(sourceLocation); | |
| collection.registerSourceLocation(sourceLocation); | |
| } | |
| String convertToHtml(String text) { | |
| List<Annotation> annotations = <Annotation>[]; | |
| codeLocations.forEach((int codeOffset, List<SourceLocation> locations) { | |
| for (SourceLocation location in locations) { | |
| annotations.add( | |
| new Annotation( | |
| collection.getIndex(location), | |
| codeOffset, | |
| location.shortText, | |
| ), | |
| ); | |
| } | |
| }); | |
| return convertAnnotatedCodeToHtml( | |
| text, | |
| annotations, | |
| colorScheme: colorScheme, | |
| elementScheme: HighlightLinkScheme(name), | |
| windowSize: 3, | |
| ); | |
| } | |
| } | |
| class ElementScheme { | |
| const ElementScheme(); | |
| String? getName(int id, Set<int> ids) => null; | |
| String? getHref(int id, Set<int> ids) => null; | |
| String? onClick(int id, Set<int> ids) => null; | |
| String? onMouseOver(int id, Set<int> ids) => null; | |
| String? onMouseOut(int id, Set<int> ids) => null; | |
| } | |
| class HighlightLinkScheme implements ElementScheme { | |
| final String name; | |
| HighlightLinkScheme(this.name); | |
| @override | |
| String getName(int id, Set<int> indices) { | |
| return 'js$id'; | |
| } | |
| @override | |
| String getHref(int id, Set<int> indices) { | |
| return "#${id}"; | |
| } | |
| @override | |
| String onClick(int id, Set<int> indices) { | |
| return "show(\'$name\');"; | |
| } | |
| @override | |
| String onMouseOut(int id, Set<int> indices) { | |
| return "highlight([]);"; | |
| } | |
| @override | |
| String onMouseOver(int id, Set<int> indices) { | |
| String onmouseover = indices.map((i) => '\'$i\'').join(','); | |
| return "highlight([${onmouseover}]);"; | |
| } | |
| } | |
| String convertAnnotatedCodeToHtml( | |
| String code, | |
| Iterable<Annotation> annotations, { | |
| CssColorScheme colorScheme = const SingleColorScheme(), | |
| required ElementScheme elementScheme, | |
| required int windowSize, | |
| }) { | |
| StringBuffer htmlBuffer = StringBuffer(); | |
| List<CodeLine> lines = convertAnnotatedCodeToCodeLines( | |
| code, | |
| annotations, | |
| windowSize: windowSize, | |
| ); | |
| int? lineNoWidth; | |
| if (lines.isNotEmpty) { | |
| lineNoWidth = '${lines.last.lineNo + 1}'.length; | |
| } | |
| HtmlPrintContext context = HtmlPrintContext( | |
| lineNoWidth: lineNoWidth, | |
| getAnnotationData: createAnnotationDataFunction( | |
| colorScheme: colorScheme, | |
| elementScheme: elementScheme, | |
| ), | |
| ); | |
| for (CodeLine line in lines) { | |
| line.printHtmlOn(htmlBuffer, context); | |
| } | |
| return htmlBuffer.toString(); | |
| } | |
| List<CodeLine> convertAnnotatedCodeToCodeLines( | |
| String code, | |
| Iterable<Annotation> annotations, { | |
| int? startLine, | |
| int? endLine, | |
| int? windowSize, | |
| Uri? uri, | |
| }) { | |
| List<CodeLine> lines = <CodeLine>[]; | |
| CodeLine? currentLine; | |
| final List<Annotation> currentAnnotations = <Annotation>[]; | |
| int offset = 0; | |
| int lineIndex = 0; | |
| late final int firstLine; | |
| late final int lastLine; | |
| void addCode(String code) { | |
| if (currentLine != null) { | |
| currentLine!.codeBuffer.write(code); | |
| currentLine!.codeParts.add( | |
| new CodePart(currentAnnotations.toList(), code), | |
| ); | |
| currentAnnotations.clear(); | |
| } | |
| } | |
| void addAnnotations(List<Annotation> annotations) { | |
| currentAnnotations.addAll(annotations); | |
| if (currentLine != null) { | |
| currentLine!.annotations.addAll(annotations); | |
| } | |
| } | |
| void beginLine(int currentOffset) { | |
| lines.add(currentLine = CodeLine(lines.length, currentOffset, uri: uri)); | |
| } | |
| void endCurrentLocation() { | |
| if (currentAnnotations.isNotEmpty) { | |
| addCode(''); | |
| } | |
| } | |
| void addSubstring(int until, {bool isFirst = false, bool isLast = false}) { | |
| if (until <= offset) return; | |
| if (offset >= code.length) return; | |
| String substring = code.substring(offset, until); | |
| bool first = true; | |
| if (isLast) { | |
| lastLine = lineIndex; | |
| } | |
| int localOffset = 0; | |
| if (isFirst) { | |
| beginLine(offset + localOffset); | |
| } | |
| for (String line in substring.split('\n')) { | |
| if (!first) { | |
| endCurrentLocation(); | |
| lineIndex++; | |
| beginLine(offset + localOffset); | |
| } | |
| addCode(line); | |
| first = false; | |
| localOffset += line.length + 1; | |
| } | |
| if (isFirst) { | |
| firstLine = lineIndex; | |
| } | |
| offset = until; | |
| } | |
| void insertAnnotations(List<Annotation> annotations) { | |
| endCurrentLocation(); | |
| addAnnotations(annotations); | |
| } | |
| Map<int, List<Annotation>> annotationMap = <int, List<Annotation>>{}; | |
| for (Annotation annotation in annotations) { | |
| annotationMap | |
| .putIfAbsent(annotation.codeOffset, () => <Annotation>[]) | |
| .add(annotation); | |
| } | |
| bool first = true; | |
| for (int codeOffset in annotationMap.keys.toList()..sort()) { | |
| List<Annotation> annotationList = annotationMap[codeOffset]!; | |
| addSubstring(codeOffset, isFirst: first); | |
| insertAnnotations(annotationList); | |
| first = false; | |
| } | |
| addSubstring(code.length, isFirst: first, isLast: true); | |
| endCurrentLocation(); | |
| int start = startLine ?? 0; | |
| int end = endLine ?? lines.length - 1; | |
| if (lastLine == 0) lastLine = firstLine; | |
| if (windowSize != null) { | |
| start = Math.max(firstLine - windowSize, start); | |
| end = Math.min(lastLine + windowSize, end); | |
| } | |
| return lines.sublist(start, end); | |
| } | |
| /// Computes the HTML representation for a collection of JavaScript code blocks. | |
| String computeJsHtml(Iterable<SourceMapHtmlInfo> infoList) { | |
| StringBuffer jsCodeBuffer = StringBuffer(); | |
| for (SourceMapHtmlInfo info in infoList) { | |
| String name = info.sourceMapInfo.name!; | |
| String html = info.codeProcessor.convertToHtml(info.sourceMapInfo.code); | |
| String onclick = 'show(\'$name\');'; | |
| jsCodeBuffer.write( | |
| '<h3 onclick="$onclick">JS code for: ${escape(name)}</h3>\n', | |
| ); | |
| jsCodeBuffer.write(''' | |
| <pre> | |
| $html | |
| </pre> | |
| '''); | |
| } | |
| return jsCodeBuffer.toString(); | |
| } | |
| /// Computes the HTML representation of the source mapping information for a | |
| /// collection of JavaScript code blocks. | |
| String computeJsTraceHtml(Iterable<SourceMapHtmlInfo> infoList) { | |
| StringBuffer jsTraceBuffer = StringBuffer(); | |
| for (SourceMapHtmlInfo info in infoList) { | |
| String name = info.sourceMapInfo.name!; | |
| String jsTrace = computeJsTraceHtmlPart( | |
| info.sourceMapInfo.codePoints, | |
| info.sourceLocationCollection, | |
| ); | |
| jsTraceBuffer.write(''' | |
| <div name="$name" class="js-trace-buffer" style="display:none;"> | |
| <h3>Trace for: ${escape(name)}</h3> | |
| $jsTrace | |
| </div> | |
| '''); | |
| } | |
| return jsTraceBuffer.toString(); | |
| } | |
| /// Computes the HTML information for the [info]. | |
| SourceMapHtmlInfo createHtmlInfo( | |
| SourceLocationCollection collection, | |
| SourceMapInfo info, | |
| ) { | |
| String name = info.name!; | |
| SourceLocationCollection subcollection = SourceLocationCollection(collection); | |
| CodeProcessor codeProcessor = CodeProcessor(name, subcollection); | |
| for (js.Node node in info.nodeMap.nodes) { | |
| info.nodeMap[node]!.forEach(( | |
| int targetOffset, | |
| List<SourceLocation> sourceLocations, | |
| ) { | |
| for (SourceLocation sourceLocation in sourceLocations) { | |
| codeProcessor.addSourceLocation(targetOffset, sourceLocation); | |
| } | |
| }); | |
| } | |
| return SourceMapHtmlInfo(info, codeProcessor, subcollection); | |
| } | |
| /// Outputs a HTML file in [jsMapHtmlUri] containing an interactive | |
| /// visualization of the source mapping information in [infoList] computed | |
| /// with the [sourceMapProcessor]. | |
| void createTraceSourceMapHtml( | |
| Uri jsMapHtmlUri, | |
| SourceMapProcessor sourceMapProcessor, | |
| Iterable<SourceMapInfo> infoList, | |
| ) { | |
| SourceFileManager sourceFileManager = sourceMapProcessor.sourceFileManager; | |
| SourceLocationCollection collection = SourceLocationCollection(); | |
| List<SourceMapHtmlInfo> htmlInfoList = <SourceMapHtmlInfo>[]; | |
| for (SourceMapInfo info in infoList) { | |
| htmlInfoList.add(createHtmlInfo(collection, info)); | |
| } | |
| String jsCode = computeJsHtml(htmlInfoList); | |
| String dartCode = computeDartHtml(sourceFileManager, htmlInfoList); | |
| String jsTraceHtml = computeJsTraceHtml(htmlInfoList); | |
| outputJsDartTrace(jsMapHtmlUri, jsCode, dartCode, jsTraceHtml); | |
| print('Trace source map html generated: $jsMapHtmlUri'); | |
| } | |
| /// Computes the HTML representation for the Dart code snippets referenced in | |
| /// [infoList]. | |
| String computeDartHtml( | |
| SourceFileManager sourceFileManager, | |
| Iterable<SourceMapHtmlInfo> infoList, | |
| ) { | |
| StringBuffer dartCodeBuffer = StringBuffer(); | |
| for (SourceMapHtmlInfo info in infoList) { | |
| dartCodeBuffer.write( | |
| computeDartHtmlPart( | |
| info.sourceMapInfo.name!, | |
| sourceFileManager, | |
| info.sourceLocationCollection, | |
| ), | |
| ); | |
| } | |
| return dartCodeBuffer.toString(); | |
| } | |
| /// Computes the HTML representation for the Dart code snippets in [collection]. | |
| String computeDartHtmlPart( | |
| String name, | |
| SourceFileManager sourceFileManager, | |
| SourceLocationCollection collection, { | |
| bool showAsBlock = false, | |
| }) { | |
| const int windowSize = 3; | |
| StringBuffer dartCodeBuffer = StringBuffer(); | |
| Map<Uri, Map<int, List<SourceLocation>>> sourceLocationMap = {}; | |
| collection.sourceLocations.forEach((SourceLocation sourceLocation) { | |
| if (sourceLocation.sourceUri == null) return; | |
| Map<int, List<SourceLocation>> uriMap = sourceLocationMap.putIfAbsent( | |
| sourceLocation.sourceUri!, | |
| () => {}, | |
| ); | |
| List<SourceLocation> lineList = uriMap.putIfAbsent( | |
| sourceLocation.line - 1, | |
| () => [], | |
| ); | |
| lineList.add(sourceLocation); | |
| }); | |
| sourceLocationMap.forEach((Uri uri, Map<int, List<SourceLocation>> uriMap) { | |
| SourceFile? sourceFile = sourceFileManager.getSourceFile(uri); | |
| if (sourceFile == null) { | |
| print('No source file for $uri'); | |
| return; | |
| } | |
| StringBuffer codeBuffer = StringBuffer(); | |
| int? firstLineIndex; | |
| int? lastLineIndex; | |
| List<int> lineIndices = uriMap.keys.toList()..sort(); | |
| int? lineNoWidth; | |
| if (lineIndices.isNotEmpty) { | |
| lineNoWidth = '${lineIndices.last + windowSize + 1}'.length; | |
| } | |
| void flush() { | |
| if (firstLineIndex != null && lastLineIndex != null) { | |
| dartCodeBuffer.write( | |
| '<h4>${uri.pathSegments.last}, ' | |
| '${firstLineIndex! - windowSize + 1}-' | |
| '${lastLineIndex! + windowSize + 1}' | |
| '</h4>\n', | |
| ); | |
| dartCodeBuffer.write('<pre>\n'); | |
| dartCodeBuffer.write('<p class="line">'); | |
| for ( | |
| int line = firstLineIndex! - windowSize; | |
| line < firstLineIndex!; | |
| line++ | |
| ) { | |
| if (line >= 0) { | |
| dartCodeBuffer.write('</p><p class="line">'); | |
| dartCodeBuffer.write(lineNumber(line, width: lineNoWidth)); | |
| dartCodeBuffer.write(sourceFile.kernelSource.getTextLine(line + 1)); | |
| } | |
| } | |
| dartCodeBuffer.write(codeBuffer); | |
| for ( | |
| int line = lastLineIndex! + 1; | |
| line <= lastLineIndex! + windowSize; | |
| line++ | |
| ) { | |
| if (line < sourceFile.lines) { | |
| dartCodeBuffer.write('</p><p class="line">'); | |
| dartCodeBuffer.write(lineNumber(line, width: lineNoWidth)); | |
| dartCodeBuffer.write(sourceFile.kernelSource.getTextLine(line + 1)); | |
| } | |
| } | |
| dartCodeBuffer.write('</p>'); | |
| dartCodeBuffer.write('</pre>\n'); | |
| firstLineIndex = null; | |
| lastLineIndex = null; | |
| } | |
| codeBuffer.clear(); | |
| } | |
| lineIndices.forEach((int lineIndex) { | |
| List<SourceLocation> locations = uriMap[lineIndex]!; | |
| if (lastLineIndex != null && | |
| lastLineIndex! + windowSize * 4 < lineIndex) { | |
| flush(); | |
| } | |
| if (firstLineIndex == null) { | |
| firstLineIndex = lineIndex; | |
| } else { | |
| for (int line = lastLineIndex! + 1; line < lineIndex; line++) { | |
| codeBuffer.write('</p><p class="line">'); | |
| codeBuffer.write(lineNumber(line, width: lineNoWidth)); | |
| codeBuffer.write(sourceFile.kernelSource.getTextLine(line + 1)); | |
| } | |
| } | |
| String line = sourceFile.kernelSource.getTextLine(lineIndex + 1)!; | |
| locations.sort((a, b) => a.offset.compareTo(b.offset)); | |
| for (int i = 0; i < locations.length; i++) { | |
| SourceLocation sourceLocation = locations[i]; | |
| int index = collection.getIndex(sourceLocation); | |
| int start = sourceLocation.column - 1; | |
| int end = line.length; | |
| if (i + 1 < locations.length) { | |
| end = locations[i + 1].column - 1; | |
| } | |
| if (i == 0) { | |
| codeBuffer.write('</p><p class="line">'); | |
| codeBuffer.write(lineNumber(lineIndex, width: lineNoWidth)); | |
| codeBuffer.write(line.substring(0, start)); | |
| } | |
| codeBuffer.write( | |
| '<a name="${index}" style="background:${toPattern(index)};" ' | |
| 'title="[${lineIndex + 1},${start + 1}]" ' | |
| 'onmouseover="highlight(\'$index\');" ' | |
| 'onmouseout="highlight();">', | |
| ); | |
| codeBuffer.write(line.substring(start, end)); | |
| codeBuffer.write('</a>'); | |
| } | |
| lastLineIndex = lineIndex; | |
| }); | |
| flush(); | |
| }); | |
| String display = showAsBlock ? 'block' : 'none'; | |
| return ''' | |
| <div name="$name" class="dart-buffer" style="display:$display;"> | |
| <h3>Dart code for: ${escape(name)}</h3> | |
| ${dartCodeBuffer} | |
| </div>'''; | |
| } | |
| /// Computes a HTML visualization of the [codePoints]. | |
| String computeJsTraceHtmlPart( | |
| List<CodePoint> codePoints, | |
| SourceLocationCollection collection, | |
| ) { | |
| StringBuffer buffer = StringBuffer(); | |
| buffer.write('<table style="width:100%;">'); | |
| buffer.write( | |
| '<tr><th>Node kind</th><th>JS code @ offset</th>' | |
| '<th>Dart code @ mapped location</th><th>file:position:name</th></tr>', | |
| ); | |
| codePoints.forEach((CodePoint codePoint) { | |
| String jsCode = truncate(codePoint.jsCode, 50); | |
| if (codePoint.sourceLocation != null) { | |
| int index = collection.getIndex(codePoint.sourceLocation!); | |
| String style = ''; | |
| if (!codePoint.isMissing) { | |
| style = 'style="background:${toColorCss(index)};" '; | |
| buffer.write( | |
| '<tr $style' | |
| 'name="trace$index" ' | |
| 'onmouseover="highlight([${index}]);"' | |
| 'onmouseout="highlight([]);">', | |
| ); | |
| } else { | |
| buffer.write('<tr>'); | |
| print('${codePoint.sourceLocation} not found in '); | |
| collection.sourceLocationIndexMap.keys | |
| .where((l) => l.sourceUri == codePoint.sourceLocation!.sourceUri) | |
| .forEach((l) => print(' $l')); | |
| } | |
| } else { | |
| buffer.write('<tr>'); | |
| } | |
| buffer.write('<td>${codePoint.kind}</td>'); | |
| buffer.write('<td class="code">${codePoint.targetOffset}:${jsCode}</td>'); | |
| if (codePoint.sourceLocation == null) { | |
| //buffer.write('<td></td>'); | |
| } else { | |
| String dartCode = truncate(codePoint.dartCode!, 50); | |
| buffer.write('<td class="code">${dartCode}</td>'); | |
| buffer.write('<td>${escape(codePoint.sourceLocation!.shortText)}</td>'); | |
| } | |
| buffer.write('</tr>'); | |
| }); | |
| buffer.write('</table>'); | |
| return buffer.toString(); | |
| } |