Skip to content
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,37 @@ module.exports = {
| useAllContextsWithNoSearchContext | boolean | `false` | Whether to show results from all the contexts if no context is provided. This option should not be used with `hideSearchBarWithNoSearchContext: true` as this would show results when there is no search context. This will duplicate indexes and might have a performance cost depending on the index sizes. |
| `forceIgnoreNoIndex` | boolean | `false` | Force enable search index even if `noIndex: true` is set, this also affects unlisted articles. |
| `fuzzyMatchingDistance` | number | `1` | Set the edit distance for fuzzy matching during searches. |
| `synonyms` | string[][] | `[]` | Configure synonyms for search queries. Each inner array contains terms that should be treated as equivalent during search. Example: `[["CSS", "styles"], ["JS", "JavaScript"]]`. Search for any term will match documents containing other terms in the same group. Case-insensitive and bidirectional. |

### Synonyms

The `synonyms` option allows you to configure terms that should be treated as equivalent during search operations. This is particularly useful for technical documentation where multiple terms refer to the same concept.

```js
// In your `docusaurus.config.js`:
module.exports = {
themes: [
[
require.resolve("@easyops-cn/docusaurus-search-local"),
{
synonyms: [
["CSS", "styles", "stylesheets"],
["JavaScript", "JS", "ECMAScript"],
["React", "ReactJS"],
["documentation", "docs"],
],
// ... other options
},
],
],
};
```

With this configuration:
- Searching for "CSS tutorial" will also match documents containing "styles tutorial" or "stylesheets tutorial"
- Searching for "JS guide" will match documents with "JavaScript guide" or "ECMAScript guide"
- All synonym relationships are bidirectional and case-insensitive
- Each term in a synonym group will expand to match all other terms in that group

### I18N

Expand Down
2 changes: 1 addition & 1 deletion docusaurus-search-local/src/client/theme/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class SearchWorker {
(doc) => doc.i === document.p
),
metadata: result.matchData.metadata as MatchMetadata,
tokens,
tokens: rawTokens, // Use original search tokens for highlighting, not expanded synonyms
score: result.score,
};
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export let removeDefaultStopWordFilter: string[] = [];
export const searchIndexUrl = "search-index{dir}.json?_=abc";
export const searchResultLimits = 8;
export let fuzzyMatchingDistance = 0;
export let synonyms: string[][] = [];

export function __setLanguage(value: string[]): void {
language = value;
Expand All @@ -15,3 +16,7 @@ export function __setRemoveDefaultStopWordFilter(value: string[]): void {
export function __setFuzzyMatchingDistance(value: number): void {
fuzzyMatchingDistance = value;
}

export function __setSynonyms(value: string[][]): void {
synonyms = value;
}
87 changes: 87 additions & 0 deletions docusaurus-search-local/src/client/utils/smartQueries.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
__setLanguage,
__setRemoveDefaultStopWordFilter,
__setFuzzyMatchingDistance,
__setSynonyms,
} from "./proxiedGeneratedConstants";
import { SmartQuery } from "../../shared/interfaces";

Expand Down Expand Up @@ -374,3 +375,89 @@ function transformQuery(query: SmartQuery): TestQuery {
.join(" "),
};
}

describe("smartQueries with synonyms", () => {
beforeEach(() => {
__setLanguage(["en"]);
__setRemoveDefaultStopWordFilter([]);
__setFuzzyMatchingDistance(0);
__setSynonyms([["CSS", "styles"], ["JavaScript", "JS"]]);
});

test.each<[string[], TestQuery[]]>([
[
["CSS"],
[
{
tokens: ["css", "style"],
keyword: "+css +style",
},
{
tokens: ["css", "style"],
keyword: "+css +style*",
},
],
],
[
["styles"],
[
{
tokens: ["css", "style"],
keyword: "+css +style",
},
{
tokens: ["css", "style"],
keyword: "+css +style*",
},
],
],
[
["JavaScript"],
[
{
tokens: ["javascript", "js"],
keyword: "+javascript +js",
},
{
tokens: ["javascript", "js"],
keyword: "+javascript +js*",
},
],
],
[
["guide", "CSS"],
[
{
tokens: ["guid", "css", "style"],
keyword: "+guid +css +style",
},
{
tokens: ["guid", "css", "style"],
keyword: "+guid +css +style*",
},
{
tokens: ["guid", "css"],
keyword: "+guid +css",
},
{
tokens: ["guid", "style"],
keyword: "+guid +style",
},
{
tokens: ["css", "style"],
keyword: "+css +style",
},
{
tokens: ["guid", "style"],
keyword: "+guid +style*",
},
{
tokens: ["css", "style"],
keyword: "+css +style*",
},
],
],
])("smartQueries(%j, []) with synonyms should work", (tokens, queries) => {
expect(smartQueries(tokens, []).map(transformQuery)).toEqual(queries);
});
});
24 changes: 21 additions & 3 deletions docusaurus-search-local/src/client/utils/smartQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { smartTerms } from "./smartTerms";
import {
language,
removeDefaultStopWordFilter,
removeDefaultStemmer,
fuzzyMatchingDistance,
synonyms,
} from "./proxiedGeneratedConstants";
import { createSynonymsMap, expandTokens } from "../../shared/synonymsUtils";

/**
* Get all possible queries for a list of tokens consists of words mixed English and Chinese,
Expand All @@ -20,15 +23,30 @@ export function smartQueries(
tokens: string[],
zhDictionary: string[]
): SmartQuery[] {
const terms = smartTerms(tokens, zhDictionary);
// Expand tokens with synonyms if configured
let expandedTokens = tokens;
if (synonyms && synonyms.length > 0) {
// Get the stemmer function if stemming is not disabled
const stemmerFn = !removeDefaultStemmer ?
(word: string) => {
const token = new lunr.Token(word, {});
const stemmedToken = lunr.stemmer(token);
return stemmedToken.toString();
} : undefined;

const synonymsMap = createSynonymsMap(synonyms, stemmerFn);
expandedTokens = expandTokens(tokens, synonymsMap, stemmerFn);
}

const terms = smartTerms(expandedTokens, zhDictionary);

if (terms.length === 0) {
// There are no matched terms.
// All tokens are considered required and with wildcard.
return [
{
tokens,
term: tokens.map((value) => ({
tokens: expandedTokens,
term: expandedTokens.map((value) => ({
value,
presence: lunr.Query.presence.REQUIRED,
wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING,
Expand Down
2 changes: 2 additions & 0 deletions docusaurus-search-local/src/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ declare module "*/generated.js" {

declare module "*/generated-constants.js" {
export const removeDefaultStopWordFilter: string[];
export const removeDefaultStemmer: boolean;
export const language: string[];
export const searchIndexUrl: string;
export const searchResultLimits: number;
export const fuzzyMatchingDistance: number;
export const synonyms: string[][];
// These below are for mocking only.
export const __setLanguage: (value: string[]) => void;
export const __setRemoveDefaultStopWordFilter: (value: string[]) => void;
Expand Down
11 changes: 11 additions & 0 deletions docusaurus-search-local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,15 @@ export interface PluginOptions {
* @default 1
*/
fuzzyMatchingDistance?: number;

/**
* Synonyms configuration for search indexing and querying. Each array contains terms that should be
* treated as equivalent during both indexing and searching.
*
* Example: [["CSS", "styles"], ["JS", "JavaScript"]]
* A search for "CSS" will also match documents containing "styles" and vice versa.
*
* @default []
*/
synonyms?: string[][];
}
52 changes: 52 additions & 0 deletions docusaurus-search-local/src/server/utils/buildIndex.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe("buildIndex", () => {
language: ["en"],
removeDefaultStopWordFilter: [] as string[],
removeDefaultStemmer: false,
synonyms: [],
} as ProcessedPluginOptions
);

Expand Down Expand Up @@ -71,6 +72,7 @@ describe("buildIndex", () => {
language: ["zh"],
removeDefaultStopWordFilter: [] as string[],
removeDefaultStemmer: false,
synonyms: [],
} as ProcessedPluginOptions
);

Expand All @@ -90,6 +92,7 @@ describe("buildIndex", () => {
language: ["es"],
removeDefaultStopWordFilter: [] as string[],
removeDefaultStemmer: false,
synonyms: [],
} as ProcessedPluginOptions
);

Expand All @@ -108,6 +111,7 @@ describe("buildIndex", () => {
language: ["ja"],
removeDefaultStopWordFilter: [] as string[],
removeDefaultStemmer: false,
synonyms: [],
} as ProcessedPluginOptions
);

Expand Down Expand Up @@ -140,6 +144,7 @@ describe("buildIndex", () => {
language: ["en", "zh"],
removeDefaultStopWordFilter: ["en"],
removeDefaultStemmer: false,
synonyms: [],
} as ProcessedPluginOptions
);

Expand All @@ -159,4 +164,51 @@ describe("buildIndex", () => {
}),
]);
});

test("should work with synonyms", () => {
const synonymsTestDocuments: Partial<SearchDocument>[][] = [
[
{
i: 1,
t: "CSS tutorial guide",
},
{
i: 2,
t: "JavaScript manual",
},
{
i: 3,
t: "Modern styles implementation",
},
],
];

const wrappedIndexes = buildIndex(
synonymsTestDocuments as SearchDocument[][],
{
language: ["en"],
removeDefaultStopWordFilter: [] as string[],
removeDefaultStemmer: false,
synonyms: [["CSS", "styles"], ["JavaScript", "JS"]],
} as ProcessedPluginOptions
);

// With synonyms expansion during indexing, searches work bidirectionally
const cssResults = wrappedIndexes[0].index.search("CSS");
expect(cssResults.length).toBe(2); // Should find both CSS document and styles document
expect(cssResults.map(r => r.ref)).toContain("1"); // CSS tutorial guide
expect(cssResults.map(r => r.ref)).toContain("3"); // Modern styles implementation

const stylesResults = wrappedIndexes[0].index.search("styles");
expect(stylesResults.length).toBe(2); // Should find both styles document and CSS document
expect(stylesResults.map(r => r.ref)).toContain("1"); // CSS tutorial guide
expect(stylesResults.map(r => r.ref)).toContain("3"); // Modern styles implementation

// Test that query expansion would find both (this mimics client-side behavior)
const synonymsQuery = "CSS OR styles";
const expandedResults = wrappedIndexes[0].index.search(synonymsQuery);
expect(expandedResults.length).toBe(2);
expect(expandedResults.map(r => r.ref)).toContain("1");
expect(expandedResults.map(r => r.ref)).toContain("3");
});
});
Loading