You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

SearchResults.tsx 5.4KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2019 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import * as React from 'react';
  21. import lunr, { LunrBuilder, LunrIndex, LunrToken } from 'lunr';
  22. import { sortBy } from 'lodash';
  23. import SearchResultEntry, { SearchResult } from './SearchResultEntry';
  24. import { DocumentationEntry, getUrlsList, DocsNavigationItem } from '../utils';
  25. interface Props {
  26. navigation: DocsNavigationItem[];
  27. pages: DocumentationEntry[];
  28. query: string;
  29. splat: string;
  30. }
  31. export default class SearchResults extends React.PureComponent<Props> {
  32. index: LunrIndex;
  33. constructor(props: Props) {
  34. super(props);
  35. this.index = lunr(function() {
  36. this.use(tokenContextPlugin);
  37. this.ref('relativeName');
  38. this.field('title', { boost: 10 });
  39. this.field('text');
  40. this.metadataWhitelist = ['position', 'tokenContext'];
  41. props.pages
  42. .filter(page => getUrlsList(props.navigation).includes(page.url))
  43. .forEach(page => this.add(page));
  44. });
  45. }
  46. render() {
  47. const query = this.props.query.toLowerCase();
  48. const results = this.index
  49. .search(
  50. query
  51. .split(/\s+/)
  52. .map(s => `${s}~1 ${s}*`)
  53. .join(' ')
  54. )
  55. .map(match => {
  56. const page = this.props.pages.find(page => page.relativeName === match.ref);
  57. const highlights: { [field: string]: [number, number][] } = {};
  58. let longestTerm = '';
  59. let exactMatch = false;
  60. // Loop over all matching terms/tokens.
  61. Object.keys(match.matchData.metadata).forEach(term => {
  62. // Remember the longest term that matches the query as close as possible.
  63. if (query.includes(term.toLowerCase()) && longestTerm.length < term.length) {
  64. longestTerm = term;
  65. }
  66. Object.keys(match.matchData.metadata[term]).forEach(fieldName => {
  67. const { position: positions, tokenContext: tokenContexts } = match.matchData.metadata[
  68. term
  69. ][fieldName];
  70. highlights[fieldName] = [...(highlights[fieldName] || []), ...positions];
  71. // Check if we have an *exact match*.
  72. if (!exactMatch && tokenContexts) {
  73. tokenContexts.forEach((tokenContext: string) => {
  74. if (!exactMatch && tokenContext.includes(query)) {
  75. exactMatch = true;
  76. }
  77. });
  78. }
  79. });
  80. });
  81. return { exactMatch, highlights, longestTerm, page, query };
  82. })
  83. .filter(result => result.page) as SearchResult[];
  84. // Re-order results by the length of the longest matched term and by exact
  85. // match (if applicable). The longer the matched term is, the higher the
  86. // chance the result is more relevant.
  87. const sortedResults = sortBy(
  88. // Sort by longest term.
  89. sortBy(results, result => -result.longestTerm.length),
  90. // Sort by exact match.
  91. result => result.exactMatch && -1
  92. );
  93. return (
  94. <>
  95. {sortedResults.map(result => (
  96. <SearchResultEntry
  97. active={result.page.relativeName === this.props.splat}
  98. key={result.page.relativeName}
  99. result={result}
  100. />
  101. ))}
  102. </>
  103. );
  104. }
  105. }
  106. // Lunr doesn't support exact multiple-term matching. Meaning "foo bar" will not
  107. // boost a sentence like "Foo bar baz" more than "Baz bar foo". In order to
  108. // provide more accurate results, we store the token context, to see if we can
  109. // perform an "exact match". Unfortunately, we cannot extend the search logic,
  110. // only the tokenizer at *index time*. This is why we store the context as
  111. // meta-data, and post-process the matches before rendering (see above). For
  112. // performance reasons, we only add 2 extra tokens, one in front, one after.
  113. // This means we support "exact macthing" for up to 3 terms. More search terms
  114. // would fallback to the regular matching algorithm, which is OK: the more terms
  115. // searched for, the better the standard algorithm will perform anyway. In the
  116. // end, the best would be for Lunr to support multi-term matching, as extending
  117. // the search algorithm for this would be way too complicated.
  118. function tokenContextPlugin(builder: LunrBuilder) {
  119. const pipelineFunction = (token: LunrToken, index: number, tokens: LunrToken[]) => {
  120. const prevToken = tokens[index - 1] || '';
  121. const nextToken = tokens[index + 1] || '';
  122. token.metadata['tokenContext'] = [prevToken.toString(), token.toString(), nextToken.toString()]
  123. .filter(s => s.length)
  124. .join(' ')
  125. .toLowerCase();
  126. return token;
  127. };
  128. (lunr as any).Pipeline.registerFunction(pipelineFunction, 'tokenContext');
  129. builder.pipeline.before((lunr as any).stemmer, pipelineFunction);
  130. builder.metadataWhitelist.push('tokenContext');
  131. }