Skip to content

Commit ffdc887

Browse files
authored
Merge pull request #15 from sylhare/simplejekyllsearch
Add simple jekyll search class
2 parents 316d34e + e83e599 commit ffdc887

File tree

12 files changed

+922
-524
lines changed

12 files changed

+922
-524
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"postcypress:run": "node scripts/kill-jekyll.js",
1313
"lint": "eslint . --ext .ts",
1414
"pretest": "yarn run lint",
15-
"build": "vite build && terser dest/simple-jekyll-search.js -o dest/simple-jekyll-search.min.js",
15+
"build": "tsc && vite build && terser dest/simple-jekyll-search.js -o dest/simple-jekyll-search.min.js",
1616
"prebuild": "yarn run test",
1717
"postbuild": "node scripts/stamp.js < dest/simple-jekyll-search.min.js > dest/simple-jekyll-search.min.js.tmp && mv dest/simple-jekyll-search.min.js.tmp dest/simple-jekyll-search.min.js && yarn run copy-example-code",
1818
"test": "vitest run --coverage",
@@ -36,12 +36,14 @@
3636
},
3737
"homepage": "https://github.com/christian-fei/Simple-Jekyll-Search",
3838
"devDependencies": {
39+
"@types/jsdom": "^21.1.7",
3940
"@types/node": "^20.11.24",
4041
"@typescript-eslint/eslint-plugin": "^8.29.0",
4142
"@typescript-eslint/parser": "^8.29.0",
4243
"@vitest/coverage-v8": "^3.1.2",
4344
"cypress": "^14.1.0",
4445
"eslint": "^9.24.0",
46+
"jsdom": "^26.1.0",
4547
"terser": "^5.39.0",
4648
"ts-node": "^10.9.2",
4749
"typescript": "^5.3.3",

src/Repository.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1+
import { FuzzySearchStrategy, LiteralSearchStrategy, WildcardSearchStrategy } from './SearchStrategies/SearchStrategy';
2+
import { Matcher } from './SearchStrategies/types';
13
import { clone, isObject } from './utils';
2-
import { RepositoryOptions } from './utils/types';
34
import { DEFAULT_OPTIONS } from './utils/default';
4-
import { Matcher } from './SearchStrategies/types';
5-
import { FuzzySearchStrategy, LiteralSearchStrategy, WildcardSearchStrategy } from './SearchStrategies/SearchStrategy';
6-
7-
interface RepositoryData {
8-
[key: string]: any;
9-
}
5+
import { RepositoryData, RepositoryOptions } from './utils/types';
106

117
export class Repository {
128
private data: RepositoryData[] = [];
13-
private options: Required<RepositoryOptions>;
9+
private options!: Required<RepositoryOptions>;
1410

1511
constructor(initialOptions: RepositoryOptions = {}) {
1612
this.setOptions(initialOptions);
@@ -40,11 +36,12 @@ export class Repository {
4036

4137
public setOptions(newOptions: RepositoryOptions): void {
4238
this.options = {
43-
fuzzy: newOptions?.fuzzy || false,
39+
fuzzy: newOptions?.fuzzy || DEFAULT_OPTIONS.fuzzy,
4440
limit: newOptions?.limit || DEFAULT_OPTIONS.limit,
45-
searchStrategy: this.searchStrategy(newOptions?.strategy || newOptions.fuzzy && 'fuzzy'),
41+
searchStrategy: this.searchStrategy(newOptions?.strategy || (newOptions.fuzzy && 'fuzzy') || DEFAULT_OPTIONS.strategy),
4642
sortMiddleware: newOptions?.sortMiddleware || DEFAULT_OPTIONS.sortMiddleware,
4743
exclude: newOptions?.exclude || DEFAULT_OPTIONS.exclude,
44+
strategy: newOptions?.strategy || DEFAULT_OPTIONS.strategy,
4845
};
4946
}
5047

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { SearchStrategy } from './types';
1+
import { fuzzySearch } from './search/fuzzySearch';
22
import { literalSearch } from './search/literalSearch';
33
import { wildcardSearch } from './search/wildcardSearch';
4-
import { fuzzySearch } from './search/fuzzySearch';
5-
4+
import { SearchStrategy } from './types';
65

76
export const LiteralSearchStrategy = new SearchStrategy(literalSearch);
8-
export const FuzzySearchStrategy = new SearchStrategy((text: string | null, criteria: string) => {
7+
export const FuzzySearchStrategy = new SearchStrategy((text: string, criteria: string) => {
98
return fuzzySearch(text, criteria) || literalSearch(text, criteria);
109
});
11-
export const WildcardSearchStrategy = new SearchStrategy((text: string | null, criteria: string) => {
10+
export const WildcardSearchStrategy = new SearchStrategy((text: string, criteria: string) => {
1211
return wildcardSearch(text, criteria) || literalSearch(text, criteria);
1312
});

src/SearchStrategies/search/literalSearch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* @param text
66
* @param criteria
77
*/
8-
export function literalSearch(text: string | null, criteria: string): boolean {
8+
export function literalSearch(text: string, criteria: string): boolean {
99
text = text.trim().toLowerCase();
1010
const pattern = criteria.endsWith(' ') ? [criteria.toLowerCase()] : criteria.trim().toLowerCase().split(' ');
1111

src/SearchStrategies/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ export interface Matcher {
33
}
44

55
export class SearchStrategy implements Matcher {
6-
private readonly matchFunction: (text: string | null, criteria: string) => boolean;
6+
private readonly matchFunction: (text: string, criteria: string) => boolean;
77

8-
constructor(matchFunction: (text: string | null, criteria: string) => boolean) {
8+
constructor(matchFunction: (text: string, criteria: string) => boolean) {
99
this.matchFunction = matchFunction;
1010
}
1111

src/SimpleJekyllSearch.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { load as loadJSON } from './JSONLoader';
2+
import { OptionsValidator } from './OptionsValidator';
3+
import { Repository } from './Repository';
4+
import { compile as compileTemplate, setOptions as setTemplaterOptions } from './Templater';
5+
import { isJSON, merge } from './utils';
6+
import { DEFAULT_OPTIONS, REQUIRED_OPTIONS, WHITELISTED_KEYS } from './utils/default';
7+
import { SearchData, SearchOptions, SearchResult, SimpleJekyllSearchInstance } from './utils/types';
8+
9+
class SimpleJekyllSearch {
10+
private options: SearchOptions;
11+
private repository: Repository;
12+
private optionsValidator: OptionsValidator;
13+
private debounceTimerHandle: NodeJS.Timeout | null = null;
14+
15+
constructor() {
16+
this.options = { ...DEFAULT_OPTIONS };
17+
this.repository = new Repository();
18+
this.optionsValidator = new OptionsValidator({
19+
required: REQUIRED_OPTIONS,
20+
});
21+
}
22+
23+
private debounce(func: () => void, delayMillis: number | null): void {
24+
if (delayMillis) {
25+
if (this.debounceTimerHandle) {
26+
clearTimeout(this.debounceTimerHandle);
27+
}
28+
this.debounceTimerHandle = setTimeout(func, delayMillis);
29+
} else {
30+
func();
31+
}
32+
}
33+
34+
private throwError(message: string): never {
35+
throw new Error(`SimpleJekyllSearch --- ${message}`);
36+
}
37+
38+
private emptyResultsContainer(): void {
39+
this.options.resultsContainer.innerHTML = '';
40+
}
41+
42+
private initWithJSON(json: SearchData[]): void {
43+
this.repository.put(json);
44+
this.registerInput();
45+
}
46+
47+
private initWithURL(url: string): void {
48+
loadJSON(url, (err, json) => {
49+
if (err) {
50+
this.throwError(`Failed to load JSON from ${url}: ${err.message}`);
51+
}
52+
this.initWithJSON(json);
53+
});
54+
}
55+
56+
private registerInput(): void {
57+
this.options.searchInput.addEventListener('input', (e: Event) => {
58+
const inputEvent = e as KeyboardEvent;
59+
if (!WHITELISTED_KEYS.has(inputEvent.key)) {
60+
this.emptyResultsContainer();
61+
this.debounce(() => {
62+
this.search((e.target as HTMLInputElement).value);
63+
}, this.options.debounceTime ?? null);
64+
}
65+
});
66+
}
67+
68+
public search(query: string): void {
69+
if (query?.trim().length > 0) {
70+
this.emptyResultsContainer();
71+
const results = this.repository.search(query) as SearchResult[];
72+
this.render(results, query);
73+
this.options.onSearch?.();
74+
}
75+
}
76+
77+
private render(results: SearchResult[], query: string): void {
78+
if (results.length === 0) {
79+
this.options.resultsContainer.insertAdjacentHTML('beforeend', this.options.noResultsText!);
80+
return;
81+
}
82+
83+
const fragment = document.createDocumentFragment();
84+
results.forEach(result => {
85+
result.query = query;
86+
const li = document.createElement('li');
87+
li.innerHTML = compileTemplate(result);
88+
fragment.appendChild(li);
89+
});
90+
91+
this.options.resultsContainer.appendChild(fragment);
92+
}
93+
94+
public init(_options: SearchOptions): SimpleJekyllSearchInstance {
95+
const errors = this.optionsValidator.validate(_options);
96+
if (errors.length > 0) {
97+
this.throwError(`Missing required options: ${REQUIRED_OPTIONS.join(', ')}`);
98+
}
99+
100+
this.options = merge<SearchOptions>(this.options, _options);
101+
102+
setTemplaterOptions({
103+
template: this.options.searchResultTemplate,
104+
middleware: this.options.templateMiddleware,
105+
});
106+
107+
this.repository.setOptions({
108+
fuzzy: this.options.fuzzy,
109+
limit: this.options.limit,
110+
sortMiddleware: this.options.sortMiddleware,
111+
strategy: this.options.strategy,
112+
exclude: this.options.exclude,
113+
});
114+
115+
if (isJSON(this.options.json)) {
116+
this.initWithJSON(this.options.json as SearchData[]);
117+
} else {
118+
this.initWithURL(this.options.json as string);
119+
}
120+
121+
const rv = {
122+
search: this.search.bind(this),
123+
};
124+
125+
this.options.success?.call(rv);
126+
return rv;
127+
}
128+
}
129+
130+
export default SimpleJekyllSearch;

src/index.ts

Lines changed: 11 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,14 @@
1-
import { load as loadJSON } from './JSONLoader';
2-
import { OptionsValidator } from './OptionsValidator';
3-
import { Repository } from './Repository';
4-
import { compile as compileTemplate, setOptions as setTemplaterOptions } from './Templater';
5-
import { isJSON, merge } from './utils';
6-
import { DEFAULT_OPTIONS, REQUIRED_OPTIONS, WHITELISTED_KEYS } from './utils/default';
7-
import { SearchData, SearchOptions, SearchResult, SimpleJekyllSearchInstance } from './utils/types';
1+
import SimpleJekyllSearchClass from './SimpleJekyllSearch';
2+
import { SearchOptions, SimpleJekyllSearchInstance } from './utils/types';
83

9-
let options: SearchOptions = { ...DEFAULT_OPTIONS };
10-
let debounceTimerHandle: NodeJS.Timeout;
4+
function SimpleJekyllSearch(options: SearchOptions): SimpleJekyllSearchInstance {
5+
const instance = new SimpleJekyllSearchClass();
6+
return instance.init(options);
7+
}
118

12-
const repository = new Repository();
13-
const optionsValidator = new OptionsValidator({
14-
required: REQUIRED_OPTIONS
15-
});
9+
export default SimpleJekyllSearch;
1610

17-
const debounce = (func: () => void, delayMillis: number | null): void => {
18-
if (delayMillis) {
19-
clearTimeout(debounceTimerHandle);
20-
debounceTimerHandle = setTimeout(func, delayMillis);
21-
} else {
22-
func();
23-
}
24-
};
25-
26-
const throwError = (message: string): never => {
27-
throw new Error(`SimpleJekyllSearch --- ${message}`);
28-
};
29-
30-
const emptyResultsContainer = (): void => {
31-
options.resultsContainer.innerHTML = '';
32-
};
33-
34-
const appendToResultsContainer = (text: string): void => {
35-
options.resultsContainer.insertAdjacentHTML('beforeend', text);
36-
};
37-
38-
const isValidQuery = (query: string): boolean => {
39-
return Boolean(query?.trim());
40-
};
41-
42-
const isWhitelistedKey = (key: string): boolean => {
43-
return !WHITELISTED_KEYS.has(key);
44-
};
45-
46-
const initWithJSON = (json: SearchData[]): void => {
47-
repository.put(json);
48-
registerInput();
49-
};
50-
51-
const initWithURL = (url: string): void => {
52-
loadJSON(url, (err, json) => {
53-
if (err) {
54-
throwError(`Failed to load JSON from ${url}: ${err.message}`);
55-
}
56-
initWithJSON(json);
57-
});
58-
};
59-
60-
const registerInput = (): void => {
61-
options.searchInput.addEventListener('input', (e: Event) => {
62-
const inputEvent = e as KeyboardEvent;
63-
if (isWhitelistedKey(inputEvent.key)) {
64-
emptyResultsContainer();
65-
debounce(() => {
66-
search((e.target as HTMLInputElement).value);
67-
}, options.debounceTime ?? null);
68-
}
69-
});
70-
};
71-
72-
const search = (query: string): void => {
73-
if (isValidQuery(query)) {
74-
emptyResultsContainer();
75-
render(repository.search(query) as SearchResult[], query);
76-
options.onSearch?.();
77-
}
78-
};
79-
80-
const render = (results: SearchResult[], query: string): void => {
81-
if (results.length === 0) {
82-
appendToResultsContainer(options.noResultsText!);
83-
return;
84-
}
85-
86-
const fragment = document.createDocumentFragment();
87-
results.forEach(result => {
88-
result.query = query;
89-
const li = document.createElement('li');
90-
li.innerHTML = compileTemplate(result);
91-
fragment.appendChild(li);
92-
});
93-
94-
options.resultsContainer.appendChild(fragment);
95-
};
96-
97-
window.SimpleJekyllSearch = function(_options: SearchOptions): SimpleJekyllSearchInstance {
98-
const errors = optionsValidator.validate(_options);
99-
if (errors.length > 0) {
100-
throwError(`Missing required options: ${REQUIRED_OPTIONS.join(', ')}`);
101-
}
102-
103-
options = merge<SearchOptions>(options, _options);
104-
105-
setTemplaterOptions({
106-
template: options.searchResultTemplate,
107-
middleware: options.templateMiddleware
108-
});
109-
110-
repository.setOptions({
111-
fuzzy: options.fuzzy,
112-
limit: options.limit,
113-
sortMiddleware: options.sortMiddleware,
114-
strategy: options.strategy,
115-
exclude: options.exclude
116-
});
117-
118-
if (isJSON(options.json)) {
119-
initWithJSON(options.json as SearchData[]);
120-
} else {
121-
initWithURL(options.json as string);
122-
}
123-
124-
const rv = {
125-
search
126-
};
127-
128-
options.success?.call(rv);
129-
return rv;
130-
};
11+
// Add to window if in browser environment
12+
if (typeof window !== 'undefined') {
13+
(window as any).SimpleJekyllSearch = SimpleJekyllSearch;
14+
}

src/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { RepositoryData } from './utils/types';
2+
13
export function merge<T>(target: T, source: Partial<T>): T {
24
return { ...target, ...source } as T;
35
}

src/utils/default.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NoSort } from '../utils';
22
import { SearchOptions } from './types';
33

4-
export const DEFAULT_OPTIONS: SearchOptions = {
4+
export const DEFAULT_OPTIONS: Required<SearchOptions> = {
55
searchInput: null!,
66
resultsContainer: null!,
77
json: [],
@@ -12,6 +12,7 @@ export const DEFAULT_OPTIONS: SearchOptions = {
1212
noResultsText: 'No results found',
1313
limit: 10,
1414
fuzzy: false,
15+
strategy: 'literal',
1516
debounceTime: null,
1617
exclude: [],
1718
onSearch: () => {}

0 commit comments

Comments
 (0)