blob: 4896dfc98f1734dc6a30dd9fb0d8b67da949c37d [file] [log] [blame]
// Copyright (c) 2022, 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:html';
import 'dart:js_util' as js_util;
final String _htmlBase = () {
final body = document.querySelector('body')!;
// If dartdoc did not add a base-href tag, we will need to add the relative
// path ourselves.
if (body.attributes['data-using-base-href'] == 'false') {
// Dartdoc stores the htmlBase in 'body[data-base-href]'.
return body.attributes['data-base-href'] ?? '';
} else {
return '';
}
}();
void init() {
final document = window.document;
var searchBox = document.getElementById('search-box') as InputElement?;
var searchBody = document.getElementById('search-body') as InputElement?;
var searchSidebar =
document.getElementById('search-sidebar') as InputElement?;
void disableSearch() {
print('Could not activate search functionality.');
searchBox?.placeholder = 'Failed to initialize search';
searchBody?.placeholder = 'Failed to initialize search';
searchSidebar?.placeholder = 'Failed to initialize search';
}
window.fetch('${_htmlBase}index.json').then((response) async {
int code = js_util.getProperty(response, 'status');
if (code == 404) {
disableSearch();
return;
}
var textPromise = js_util.callMethod<Object>(response, 'text', []);
var text = await promiseToFuture<String>(textPromise);
var jsonIndex = (jsonDecode(text) as List).cast<Map<String, dynamic>>();
final index = jsonIndex.map(IndexItem.fromMap).toList();
// Navigate to the first result from the 'search' query parameter
// if specified and found.
final url = Uri.parse(window.location.toString());
final search = url.queryParameters['search'];
if (search != null) {
final matches = findMatches(index, search);
if (matches.isNotEmpty) {
final href = matches.first.href;
if (href != null) {
window.location.assign('$_htmlBase$href');
return;
}
}
}
// Initialize all three search fields.
if (searchBox != null) {
initializeSearch(searchBox, index);
}
if (searchBody != null) {
initializeSearch(searchBody, index);
}
if (searchSidebar != null) {
initializeSearch(searchSidebar, index);
}
});
}
const weights = {
'library': 2,
'class': 2,
'mixin': 3,
'extension': 3,
'typedef': 3,
'method': 4,
'accessor': 4,
'operator': 4,
'constant': 4,
'property': 4,
'constructor': 4,
};
List<IndexItem> findMatches(List<IndexItem> index, String query) {
if (query.isEmpty) {
return [];
}
var allMatches = <SearchMatch>[];
for (var element in index) {
void score(int value) {
value -= (element.overriddenDepth ?? 0) * 10;
var weightFactor = weights[element.type] ?? 4;
allMatches.add(SearchMatch(element, value / weightFactor));
}
var name = element.name;
var qualifiedName = element.qualifiedName;
var lowerName = name.toLowerCase();
var lowerQualifiedName = qualifiedName.toLowerCase();
var lowerQuery = query.toLowerCase();
if (name == query || qualifiedName == query || name == 'dart:$query') {
score(2000);
} else if (lowerName == 'dart:$lowerQuery') {
score(1800);
} else if (lowerName == lowerQuery || lowerQualifiedName == lowerQuery) {
score(1700);
} else if (query.length > 1) {
if (name.startsWith(query) || qualifiedName.startsWith(query)) {
score(750);
} else if (lowerName.startsWith(lowerQuery) ||
lowerQualifiedName.startsWith(lowerQuery)) {
score(650);
} else if (name.contains(query) || qualifiedName.contains(query)) {
score(500);
} else if (lowerName.contains(lowerQuery) ||
lowerQualifiedName.contains(query)) {
score(400);
}
}
}
allMatches.sort((SearchMatch a, SearchMatch b) {
var x = (b.score - a.score).round();
if (x == 0) {
return a.element.name.length - b.element.name.length;
}
return x;
});
return allMatches.map((match) => match.element).toList();
}
const minLength = 1;
int suggestionLimit = 10;
int suggestionLength = 0;
const HtmlEscape htmlEscape = HtmlEscape();
Map<String, Element> containerMap = {};
void initializeSearch(
InputElement input,
List<IndexItem> index,
) {
final uri = Uri.parse(window.location.href);
input.disabled = false;
input.setAttribute('placeholder', 'Search API Docs');
// Handle grabbing focus when the users types / outside of the input
document.addEventListener('keydown', (Event event) {
if (event is! KeyboardEvent) {
return;
}
if (event.key == '/' && document.activeElement is! InputElement) {
event.preventDefault();
input.focus();
}
});
// Prepare elements.
var wrapper = document.createElement('div');
wrapper.classes.add('tt-wrapper');
input.replaceWith(wrapper);
input.setAttribute('autocomplete', 'off');
input.setAttribute('spellcheck', 'false');
input.classes.add('tt-input');
wrapper.append(input);
var listBox = document.createElement('div');
listBox.setAttribute('role', 'listbox');
listBox.setAttribute('aria-expanded', 'false');
listBox.style.display = 'none';
listBox.classes.add('tt-menu');
// Element use in listbox to inform the functionality of hitting enter in search box.
var moreResults = document.createElement('div');
moreResults.classes.add('enter-search-message');
listBox.append(moreResults);
// Element that contains the search suggestions in a new format.
var searchResults = document.createElement('div');
searchResults.classes.add('tt-search-results');
listBox.append(searchResults);
wrapper.append(listBox);
String? storedValue;
var actualValue = '';
var suggestionElements = <Element>[];
var suggestionsInfo = <IndexItem>[];
int? selectedElement;
void showSuggestions() {
if (searchResults.hasChildNodes()) {
listBox.style.display = 'block';
listBox.setAttribute('aria-expanded', 'true');
}
}
/// Creates the content displayed in the main-content element, for the search
/// results page.
void showSearchResultPage(String searchText) {
final mainContent = document.getElementById('dartdoc-main-content');
if (mainContent == null) {
return;
}
mainContent
..text = ''
..append(document.createElement('section')..classes.add('search-summary'))
..append(document.createElement('h2')..innerHtml = 'Search Results')
..append(document.createElement('div')
..classes.add('search-summary')
..innerHtml = '$suggestionLength results for "$searchText"');
if (containerMap.isNotEmpty) {
for (final element in containerMap.values) {
mainContent.append(element);
}
} else {
var noResults = document.createElement('div')
..classes.add('search-summary')
..innerHtml =
'There was not a match for "$searchText". Want to try searching '
'from additional Dart-related sites? ';
var buildLink = Uri.parse(
'https://dart.dev/search?cx=011220921317074318178%3A_yy-tmb5t_i&ie=UTF-8&hl=en&q=')
.replace(queryParameters: {'q': searchText});
var link = document.createElement('a')
..setAttribute('href', buildLink.toString())
..classes.add('seach-options')
..text = 'Search on dart.dev.';
noResults.append(link);
mainContent.append(noResults);
}
}
void hideSuggestions() {
listBox.style.display = 'none';
listBox.setAttribute('aria-expanded', 'false');
}
void showEnterMessage() {
moreResults.text = suggestionLength > 10
? 'Press "Enter" key to see all $suggestionLength results'
: '';
}
void updateSuggestions(String query, List<IndexItem> suggestions) {
suggestionsInfo = [];
suggestionElements = [];
containerMap = <String, Element>{};
searchResults.text = '';
if (suggestions.length < minLength) {
hideSuggestions();
return;
}
for (final suggestion in suggestions) {
suggestionElements.add(_createSuggestion(query, suggestion));
}
for (final element in containerMap.values) {
searchResults.append(element);
}
suggestionsInfo = suggestions;
selectedElement = null;
showSuggestions();
showEnterMessage();
}
var body = document.querySelector('body')!;
String relativePath() {
var relativePath = '';
if (body.getAttribute('data-using-base-href') == 'true') {
relativePath = body.getAttribute('href')!;
} else if (body.getAttribute('data-base-href') == '') {
relativePath = './';
} else {
relativePath = body.getAttribute('data-base-href')!;
}
var href = Uri.parse(window.location.href);
var base = href.resolve(relativePath);
var search = Uri.parse('${base}search.html');
return search.toString();
}
/// Handles [searchText] by generating suggestions.
void handleSearch(String? searchText, {bool forceUpdate = false}) {
if (actualValue == searchText && !forceUpdate) {
return;
}
if (searchText == null || searchText.isEmpty) {
updateSuggestions('', []);
return;
}
var suggestions = findMatches(index, searchText);
suggestionLength = suggestions.length;
if (suggestions.length > suggestionLimit) {
suggestions = suggestions.sublist(0, suggestionLimit);
}
actualValue = searchText;
updateSuggestions(searchText, suggestions);
}
// Hook up events.
input.addEventListener('focus', (Event event) {
handleSearch(input.value, forceUpdate: true);
});
input.addEventListener('blur', (Event event) {
selectedElement = null;
if (storedValue != null) {
input.value = storedValue;
storedValue = null;
}
hideSuggestions();
});
input.addEventListener('input', (event) {
handleSearch(input.value);
});
input.addEventListener('keydown', (Event event) {
if (event.type != 'keydown') {
return;
}
event = event as KeyboardEvent;
if (event.code == 'Enter') {
if (selectedElement != null) {
var selectingElement = selectedElement ?? 0;
var href = suggestionElements[selectingElement].dataset['href'];
if (href != null) {
window.location.assign('$_htmlBase$href');
}
return;
}
// If there is no search suggestion selected, then change the window
// location to `search.html`.
else {
var input = htmlEscape.convert(actualValue);
var search = Uri.parse(relativePath());
search = search.replace(queryParameters: {'q': input});
window.location.assign(search.toString());
}
}
var lastIndex = suggestionElements.length - 1;
var previousSelectedElement = selectedElement;
if (event.code == 'ArrowUp') {
if (selectedElement == null) {
selectedElement = lastIndex;
} else if (selectedElement == 0) {
selectedElement = null;
} else {
selectedElement = selectedElement! - 1;
}
} else if (event.code == 'ArrowDown') {
if (selectedElement == null) {
selectedElement = 0;
} else if (selectedElement == lastIndex) {
selectedElement = null;
} else {
selectedElement = selectedElement! + 1;
}
} else {
if (storedValue != null) {
storedValue = null;
handleSearch(input.value);
}
return;
}
if (previousSelectedElement != null) {
suggestionElements[previousSelectedElement].classes.remove('tt-cursor');
}
if (selectedElement != null) {
var selected = suggestionElements[selectedElement!];
selected.classes.add('tt-cursor');
// Guarantee the selected element is visible
if (selectedElement == 0) {
listBox.scrollTop = 0;
} else if (selectedElement == lastIndex) {
listBox.scrollTop = listBox.scrollHeight;
} else {
var offsetTop = selected.offsetTop;
var parentOffsetHeight = listBox.offsetHeight;
if (offsetTop < parentOffsetHeight ||
parentOffsetHeight < (offsetTop + selected.offsetHeight)) {
selected.scrollIntoView();
}
}
// Store the actual input value to display their currently selected item.
storedValue ??= input.value;
input.value = suggestionsInfo[selectedElement!].name;
} else if (storedValue != null && previousSelectedElement != null) {
// They are moving back to the input field, so return the stored value.
input.value = storedValue;
storedValue = null;
}
event.preventDefault();
});
// Verifying the href to check if the search html was called to generate the main content elements that are going to be displayed.
if (window.location.href.contains('search.html')) {
var input = uri.queryParameters['q'];
if (input == null) {
return;
}
input = htmlEscape.convert(input);
suggestionLimit = suggestionLength;
handleSearch(input);
showSearchResultPage(input);
hideSuggestions();
suggestionLimit = 10;
}
}
Element _createSuggestion(String query, IndexItem match) {
final suggestion = document.createElement('div')
..setAttribute('data-href', match.href ?? '')
..classes.add('tt-suggestion');
final suggestionTitle = document.createElement('span')
..classes.add('tt-suggestion-title')
..innerHtml =
_highlight('${match.name} ${match.type.toLowerCase()}', query);
suggestion.append(suggestionTitle);
final enclosingElement = match.enclosedBy;
if (enclosingElement != null) {
suggestion.append(document.createElement('span')
..classes.add('tt-suggestion-container')
..innerHtml = '(in ${_highlight(enclosingElement.name, query)})');
}
// The one line description to use in the search suggestions.
if (match.desc != '') {
final inputDescription = document.createElement('blockquote')
..classes.add('one-line-description')
..attributes['title'] = _decodeHtml(match.desc.toString())
..innerHtml = _highlight(match.desc.toString(), query);
suggestion.append(inputDescription);
}
suggestion.addEventListener('mousedown', (event) {
event.preventDefault();
});
suggestion.addEventListener('click', (event) {
if (match.href != null) {
window.location.assign('$_htmlBase${match.href}');
event.preventDefault();
}
});
if (enclosingElement != null) {
_mapToContainer(
_createContainer(
'${enclosingElement.name} ${enclosingElement.type}',
enclosingElement.href,
),
suggestion,
);
}
return suggestion;
}
/// Maps a suggestion library/class [Element] to the other suggestions, if any.
void _mapToContainer(Element containerElement, Element suggestion) {
final input = containerElement.innerHtml;
if (input == null) {
return;
}
final element = containerMap[input];
if (element != null) {
element.append(suggestion);
} else {
containerElement.append(suggestion);
containerMap[input] = containerElement;
}
}
/// Creates an `<a>` [Element] for enclosing library/class.
Element _createContainer(String encloser, String href) =>
document.createElement('div')
..classes.add('tt-container')
..append(document.createElement('p')
..text = 'Results from '
..classes.add('tt-container-text')
..append(document.createElement('a')
..setAttribute('href', href)
..innerHtml = encloser));
/// Wraps [query] in [text] with a `<strong>` tag, as HTML text.
String _highlight(String text, String query) {
final sanitizedText = const HtmlEscape().convert(query);
return text.replaceAll(
query, "<strong class='tt-highlight'>$sanitizedText</strong>");
}
/// Decodes HTML entities (like `&lt;`) into their HTML elements (like `<`).
///
/// This is safe for use in an HTML attribute like `title`.
String _decodeHtml(String html) {
return ((document.createElement('textarea') as TextAreaElement)
..innerHtml = html)
.value!;
}
class SearchMatch {
final IndexItem element;
final double score;
SearchMatch(this.element, this.score);
}
class IndexItem {
final String name;
final String qualifiedName;
final String type;
final String? href;
final int? overriddenDepth;
final String? desc;
final EnclosedBy? enclosedBy;
IndexItem._({
required this.name,
required this.qualifiedName,
required this.type,
this.desc,
this.href,
this.overriddenDepth,
this.enclosedBy,
});
// "name":"dartdoc",
// "qualifiedName":"dartdoc",
// "href":"dartdoc/dartdoc-library.html",
// "type":"library",
// "overriddenDepth":0,
// "packageName":"dartdoc"
// ["enclosedBy":{"name":"Accessor","type":"class"}]
factory IndexItem.fromMap(Map<String, dynamic> data) {
// Note that this map also contains 'packageName', but we're not currently
// using that info.
EnclosedBy? enclosedBy;
if (data['enclosedBy'] != null) {
final map = data['enclosedBy'] as Map<String, dynamic>;
enclosedBy =
EnclosedBy._(name: map['name'], type: map['type'], href: map['href']);
}
return IndexItem._(
name: data['name'],
qualifiedName: data['qualifiedName'],
href: data['href'],
type: data['type'],
overriddenDepth: data['overriddenDepth'],
desc: data['desc'],
enclosedBy: enclosedBy,
);
}
}
class EnclosedBy {
final String name;
final String type;
final String href;
// Built from JSON structure:
// ["enclosedBy":{"name":"Accessor","type":"class","href":"link"}]
EnclosedBy._({required this.name, required this.type, required this.href});
}