diff --git a/_config.yml b/_config.yml
index 61c2d98..5230181 100644
--- a/_config.yml
+++ b/_config.yml
@@ -104,11 +104,14 @@ JB :
# These paths are to the main pages Jekyll-Bootstrap ships with.
# Some JB helpers refer to these paths; change them here if needed.
#
- archive_path : /archive/index.html
+ archive_path : /archive
+ packages_path : /packages/name
+ tags_path : /packages/tag
categories_path : /packages/category
- tags_path : /packages/tag/
+ search_path : /search
atom_path : /feed/atom.xml
rss_path : /feed/rss.xml
+ json_path : /feed/packages.json
# Settings for comments helper
# Set 'provider' to the comment provider you want to use.
diff --git a/_includes/themes/the-program/default.html b/_includes/themes/the-program/default.html
index b00b9fd..ae11e27 100644
--- a/_includes/themes/the-program/default.html
+++ b/_includes/themes/the-program/default.html
@@ -27,12 +27,12 @@
- {{ site.title }}
- - Packages by Name
- - Packages by Tag
- - Packages by Category
+ - Packages by Name
+ - Packages by Tag
+ - Packages by Category
-
- - Search Packages
+ - Search Packages
-
diff --git a/assets/custom.css b/assets/custom.css
index 7f9fd2c..2c12772 100644
--- a/assets/custom.css
+++ b/assets/custom.css
@@ -99,3 +99,60 @@ tr:hover td {
column-span: all;
margin-bottom: 0.25em;
}
+
+#search + #results {
+ margin-top: 1em;
+}
+#search + #results .noResults img {
+ margin: 0 auto;
+ width: 10vw;
+}
+#search + #results li {
+ border-left: 3px solid rgba(0, 0, 0, 0);
+ list-style-type: none;
+}
+#search + #results li.selected {
+ border-left-color: rgba(254, 121, 49, 0.5);
+}
+#search + #results li .result {
+ border-top: 1px solid rgba(0, 0, 0, 0.25);
+}
+#search + #results li:first-child .result {
+ border-top: 0;
+}
+#search + #results .result {
+ font-size: 0.85em;
+ line-height: 1.5em;
+ margin-left: 0.5em;
+ padding-left: 0.5em;
+}
+#search + #results .result a {
+ text-decoration: none;
+}
+#search + #results .result p {
+ margin: 0;
+}
+#search + #results .result .result-title {
+ font-size: 1.15em;
+ line-height: 2em;
+}
+#search + #results .result label {
+ font-weight: bolder;
+}
+#search + #results .result .result-repository {
+ font-size: 0.9em;
+}
+.highlight.match-group-0 { background-color: #d5ebff; }
+.highlight.match-group-1 { background-color: #c4e8ac; }
+.highlight.match-group-2 { background-color: #f6d7a6; }
+.highlight.match-group-3 { background-color: #c8c8ff; }
+.highlight.match-group-4 { background-color: #f2cfff; }
+.highlight.match-group-5 { background-color: #ffc5bf; }
+.highlight.match-group-6 { background-color: #c8e7d6; }
+.highlight.match-group-7 { background-color: #9fcfff; }
+.highlight.match-group-8 { background-color: #9fcba1; }
+.highlight.match-group-9 { background-color: #e0bf8b; }
+.highlight.match-group-10 { background-color: #acadfc; }
+.highlight.match-group-11 { background-color: #e1abf5; }
+.highlight.match-group-12 { background-color: #ef9b95; }
+.highlight.match-group-13 { background-color: #abe2d0; }
diff --git a/assets/js/module/search.mjs b/assets/js/module/search.mjs
new file mode 100644
index 0000000..f24644f
--- /dev/null
+++ b/assets/js/module/search.mjs
@@ -0,0 +1,493 @@
+// https://github.com/jaywcjlove/hotkeys
+import { default as hotkeys } from "//unpkg.com/hotkeys-js@3.10.2/dist/hotkeys.esm.js";
+// https://github.com/krisk/Fuse
+import { default as Fuse } from '//cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.esm.min.js'
+
+export class PackageData {
+ title;
+ date;
+ author;
+ description;
+ category;
+ tags;
+ repository;
+ url;
+
+ constructor(data) {
+ this.title = data.title;
+ this.date = data.date;
+ this.author = data.author;
+ this.description = data.description;
+ this.category = data.category;
+ this.tags = data.tags;
+ this.repository = data.repository;
+ this.url = data.url;
+ }
+}
+
+export class PackageSearch {
+ _feed;
+ _templates = {};
+ _queryField;
+ _searchDelay = 500;
+ _resultsField;
+
+ _packages;
+ _fuse;
+
+ _fuseOptions = {
+ includeScore: true,
+ includeMatches: true,
+ findAllMatches: true,
+ useExtendedSearch: true,
+ keys: [
+ {weight: 2.0, name: 'title'},
+ {weight: 0.5, name: 'date'},
+ {weight: 1.0, name: 'author'},
+ {weight: 1.5, name: 'description'},
+ {weight: 0.5, name: 'category'},
+ {weight: 0.5, name: 'tags'},
+ {weight: 0.1, name: 'repository'},
+ ],
+ };
+
+ _hotkeys = {
+ enabled: true,
+ search: '/',
+ resultPrevious: 'up',
+ resultNext: 'down',
+ resultSelect: 'enter',
+ };
+ __hotkeysFilter;
+ _selectedResult;
+
+ constructor(options) {
+ if (options instanceof Object) {
+ this.options = options;
+ }
+ }
+
+ set options(options) {
+ if (! options instanceof Object) {
+ throw new TypeError('Valid options object not provided: ' + typeof options);
+ }
+
+ [
+ 'feed',
+ 'hotkeys',
+ 'templates',
+ 'queryField',
+ 'searchDelay',
+ 'resultsField',
+ ].forEach(prop => {
+ if (options.hasOwnProperty(prop))
+ this[prop] = options[prop];
+ });
+ }
+
+ get options() {
+ let options = {
+ feed: this.feed,
+ hotkeys: this.hotkeys,
+ templates: this.templates,
+ queryField: this.queryField,
+ searchDelay: this.searchDelay,
+ resultsField: this.resultsField,
+ };
+
+ Object.keys(options).forEach(key => options[key] === undefined && delete options[key]);
+
+ return options;
+ }
+
+ set feed(url) {
+ if (typeof url !== 'string') {
+ throw new TypeError('Valid search feed URI not provided');
+ }
+
+ this._feed = url;
+ }
+
+ get feed() {
+ return this._feed;
+ }
+
+ set hotkeys(hotkeys) {
+ if (typeof hotkeys === 'boolean') {
+ this._hotkeys.enabled = hotkeys;
+ } else if (! hotkeys instanceof Object) {
+ throw new TypeError("Valid hotkeys configuration object not provided: " + (typeof hotkeys));
+ } else {
+ [
+ 'enabled',
+ 'search',
+ 'resultPrevious',
+ 'resultNext',
+ 'resultSelect',
+ ].forEach(prop => {
+ if (hotkeys.hasOwnProperty(prop)) {
+ this._hotkeys[prop] = hotkeys[prop];
+ }
+ });
+ }
+ }
+
+ get hotkeys() {
+ return this._hotkeys;
+ }
+
+ set templates(templates) {
+ if (! templates instanceof Object) {
+ throw new TypeError("Valid templates object not provided");
+ }
+
+ [
+ 'noQuery',
+ 'noResults',
+ 'result',
+ ].forEach(template => {
+ if (templates.hasOwnProperty(template)) {
+ let tag = templates[template];
+
+ if (typeof tag === 'string') {
+ tag = document.createElement('template');
+ tag.innerHTML = templates[template];
+ }
+
+ if (tag instanceof Element) {
+ this._templates[template] = tag;
+ }
+ }
+ });
+ }
+
+ get templates() {
+ return this._templates;
+ }
+
+ set queryField(element) {
+ if (! element instanceof Element || element.tagName.toLowerCase() !== 'input') {
+ throw new TypeError('Valid query box form field not provided');
+ }
+
+ this._queryField = element;
+ }
+
+ get queryField() {
+ return this._queryField;
+ }
+
+ set searchDelay(delay) {
+ if (typeof delay !== 'number' || ! Number.isInteger(delay)) {
+ throw new TypeError('Valid search delay integer not provided');
+ }
+
+ this._searchDelay = delay;
+ }
+
+ get searchDelay() {
+ return this._searchDelay;
+ }
+
+ set resultsField(element) {
+ if (! element instanceof Element) {
+ throw new TypeError('Valid search results element not provided');
+ }
+
+ this._resultsField = element;
+ }
+
+ get resultsField() {
+ return this._resultsField;
+ }
+
+ set packages(packages) {
+ if (! packages instanceof Object) {
+ throw new TypeError('Valid packages not provided');
+ }
+
+ this._packages = {
+ generated: new Date(packages.generated),
+ items: packages.posts.map(item => new PackageData(item)),
+ };
+
+ this.fuse = this._packages.items;
+ }
+
+ get packages() {
+ return this._packages;
+ }
+
+ set fuse(items) {
+ if (! items instanceof Array) {
+ throw new TypeError("Valid fuse set not provided");
+ }
+
+ this._fuse = new Fuse(
+ items,
+ this._fuseOptions,
+ Fuse.createIndex(
+ this._fuseOptions.keys,
+ items
+ )
+ );
+ }
+
+ get fuse() {
+ return this._fuse;
+ }
+
+ set selectedResult(select) {
+ let resultElement = undefined;
+
+ if (this._selectedResult instanceof Element) {
+ this._selectedResult.classList.remove('selected');
+
+ if(select === 'next') {
+ resultElement = this._selectedResult.nextElementSibling instanceof Element
+ ? this._selectedResult.nextElementSibling
+ : this.resultsField.querySelector('ul li')
+ } else if (select === 'previous') {
+ resultElement = this._selectedResult.previousElementSibling instanceof Element
+ ? this._selectedResult.previousElementSibling
+ : this.resultsField.querySelector('ul li:last-child');
+ }
+ } else {
+ if(select === 'next') {
+ resultElement = this.resultsField.querySelector('ul li')
+ } else if (select === 'previous') {
+ resultElement = this.resultsField.querySelector('ul li:last-child');
+ }
+ }
+
+ if (resultElement instanceof Element) {
+ resultElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ resultElement.classList.add('selected');
+ }
+
+ this._selectedResult = resultElement;
+ }
+
+ get selectedResult() {
+ return this._selectedResult;
+ }
+
+ isSetup() {
+ return this.feed !== undefined &&
+ this.queryField !== undefined &&
+ this.resultsField !== undefined;
+ }
+
+ initialize() {
+ if (! this.isSetup()) {
+ throw new Error('Search is not setup correctly prior to initialization');
+ }
+
+ this._initializeHotKeys();
+ this._initializeTemplates();
+ this._initializeQueryField();
+ this._initializeResultsField();
+ }
+
+ _initializeHotKeys() {
+ if (this.hotkeys.enabled) {
+ this.__hotkeysFilter = hotkeys.filter;
+ hotkeys.filter = this._hotKeysFilter.bind(this);
+
+ if (typeof this.hotkeys.search === 'string') {
+ hotkeys(this.hotkeys.search, this._hotKeysSearch.bind(this));
+ }
+ if (typeof this.hotkeys.resultPrevious === 'string') {
+ hotkeys(this.hotkeys.resultPrevious, this._hotKeysResultPrevious.bind(this));
+ }
+ if (typeof this.hotkeys.resultNext === 'string') {
+ hotkeys(this.hotkeys.resultNext, this._hotKeysResultNext.bind(this));
+ }
+ if (typeof this.hotkeys.resultSelect === 'string') {
+ hotkeys(this.hotkeys.resultSelect, this._hotKeysResultSelect.bind(this));
+ }
+ }
+ }
+
+ _initializeTemplates() {
+ if (this.templates.noQuery === undefined) {
+ this.templates = { noQuery: this.resultsField.innerHTML === ''
+ ? 'No query provided.'
+ : this.resultsField.innerHTML
+ };
+ }
+ if (this.templates.noResults === undefined) {
+ this.templates = { noResults: 'Zero results found.' };
+ }
+ if (this.templates.result === undefined) {
+ this.templates = { result: '${data.item.title}' };
+ }
+ }
+
+ _initializeQueryField() {
+ if (document.activeElement === this.queryField) {
+ this._fetchFeed();
+ } else {
+ this.queryField.addEventListener(
+ 'focus',
+ () => this._fetchFeed(),
+ {
+ capture: true,
+ once: true,
+ passive: true,
+ }
+ );
+ }
+
+ let searchTimeout = null;
+
+ this.queryField.addEventListener(
+ 'input',
+ () => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(
+ () => this._search(this.queryField.value.toLowerCase()),
+ this.searchDelay
+ );
+ }
+ );
+ }
+
+ _initializeResultsField() {
+ this._setNoResults();
+ }
+
+ _hotKeysFilter(event) {
+ return [
+ hotkeys.keyMap[this._hotkeys.resultPrevious.toLowerCase()],
+ hotkeys.keyMap[this._hotkeys.resultNext.toLowerCase()],
+ hotkeys.keyMap[this._hotkeys.resultSelect.toLowerCase()],
+ ].includes(event.keyCode)
+ ? true
+ : this.__hotkeysFilter(event);
+ }
+
+ _hotKeysSearch(event) {
+ event.preventDefault();
+ this.queryField.focus()
+ }
+
+ _hotKeysResultPrevious(event) {
+ event.preventDefault();
+ this.selectedResult = 'previous';
+ }
+
+ _hotKeysResultNext(event) {
+ event.preventDefault();
+ this.selectedResult = 'next';
+ }
+
+ _hotKeysResultSelect() {
+ if (this.selectedResult instanceof Element) {
+ let link = this.selectedResult.querySelector('a');
+ if (link !== null) {
+ link.click();
+ }
+ }
+ }
+
+ _fetchFeed() {
+ fetch(this.feed)
+ .then(response => response.json())
+ .then(() => console.log('Search feed fetched'))
+ .then(data => this.packages = data)
+ .then(() => console.log('Search feed loaded'))
+ .catch(err => console.warn('Failed to load search feed:', err));
+ console.log('Fetching search feed');
+ }
+
+ _getStringFromTemplate(template, data) {
+ if (! this.templates.hasOwnProperty(template)) {
+ throw new Error('Valid search template not provided: ' + template);
+ } else if (! data instanceof Object) {
+ throw new TypeError('Valid result object not provided: ' + typeof data);
+ }
+
+ try {
+ return eval('`' + this.templates[template].innerHTML + '`');
+ } catch (error) {
+ console.log('Issue with configured template (' + template + '): ' + this.templates[template].innerHTML, data);
+ }
+ }
+
+ _search(query) {
+ this._showResults(
+ this._highlightResults(
+ this.fuse.search(query)
+ )
+ );
+ }
+
+ _highlightResults(results) {
+ if (this._fuseOptions.hasOwnProperty('includeMatches') && this._fuseOptions.includeMatches) {
+ results = results.map(result => {
+ // Deep copy
+ let highlight = JSON.parse(JSON.stringify(result.item));
+
+ result.matches.forEach(match => {
+ if (match.key === 'tags') {
+ highlight.tags[match.refIndex] = this._highlightString(match.value, match.indices);
+ } else if (highlight.hasOwnProperty(match.key)) {
+ highlight[match.key] = this._highlightString(match.value, match.indices);
+ }
+ });
+
+ result.highlight = highlight;
+ return result;
+ });
+ }
+
+ return results;
+ }
+
+ _highlightString(string, indices) {
+ let markers = [];
+
+ indices.forEach(index => {
+ markers.push([index[0], true]);
+ markers.push([index[1], false]);
+ });
+
+ string = string.split('');
+ let match = 0;
+
+ markers.forEach(mark => {
+ string[mark[0]] = mark[1]
+ ? '' + string[mark[0]]
+ : string[mark[0]] + '';
+ });
+
+ return string.join('');
+ }
+
+ _setNoResults() {
+ this.resultsField.innerHTML = this._getStringFromTemplate(
+ this.queryField.value.trim() === '' ? 'noQuery' : 'noResults'
+ );
+ }
+
+ _showResults(results) {
+ this.selectedResult = null;
+ this.resultsField.replaceChildren();
+
+ if (results === undefined || results.length === 0) {
+ this._setNoResults();
+ } else {
+ const ul = document.createElement('ul');
+
+ results.forEach(result => {
+ const li = document.createElement('li');
+ li.innerHTML = this._getStringFromTemplate('result', result);
+
+ ul.appendChild(li);
+ });
+
+ this.resultsField.appendChild(ul);
+ }
+ }
+}
diff --git a/assets/js/search.js b/assets/js/search.js
deleted file mode 100644
index f77ac1d..0000000
--- a/assets/js/search.js
+++ /dev/null
@@ -1,61 +0,0 @@
-class PackageData {
- title;
- date;
- author;
- description;
- category;
- tags;
- url;
-
- constructor(data) {
- this.title = data.title;
- this.date = data.date;
- this.author = data.author;
- this.description = data.description;
- this.category = data.category;
- this.tags = data.tags;
- this.url = data.url;
- }
-}
-
-document.addEventListener('DOMContentLoaded', function (event) {
- const search = document.getElementById('search');
- const results = document.getElementById('results');
- let generated = '';
- let data = [];
- let search_term = '';
-
- fetch('/feed/search.json')
- .then((response) => response.json())
- .then((data_server) => {
- generated = data_server.generated;
- data = data_server.posts.map(post => new PackageData(post));
- });
-
- search.addEventListener('input', (event) => {
- search_term = event.target.value.toLowerCase();
- showList();
- });
-
- const showList = () => {
- results.innerHTML = '';
-
- if (search_term.length == 0) return;
-
- const match = new RegExp(`${search_term}`, 'gi');
- let result = data.filter((name) => match.test(name.title));
-
- if (result.length == 0) {
- const li = document.createElement('li');
- li.innerHTML = `Zero results found`;
- results.appendChild(li);
- }
-
- result.forEach((package) => {
- const li = document.createElement('li');
- li.innerHTML = `${package.title}`;
- results.appendChild(li);
- });
- };
-
-});
diff --git a/feed/search.json b/feed/packages.liquid
similarity index 69%
rename from feed/search.json
rename to feed/packages.liquid
index 19edf11..b06b7e1 100644
--- a/feed/search.json
+++ b/feed/packages.liquid
@@ -1,6 +1,6 @@
---
-title : JavaScript Search
-permalink : '/feed/search.json'
+title : JSON Packages Feed
+permalink : '/feed/packages.json'
---
{
@@ -14,8 +14,8 @@ permalink : '/feed/search.json'
"category":"{{ post.category }}",
"tags":{{ post.tags | jsonify }},
"repository":"{{ post.repository }}",
- "url":"{{ site.production_url }}{{ post.url }}"
- }{% if forloop.last == false %},{% endif %}
- {% endfor %}
+ "url":"{{ post.url }}"
+ }{% if forloop.last == false %},
+ {% else %}{% endif %}{% endfor %}
]
}
diff --git a/search/index.html b/search/index.html
index f28bc25..cc63143 100644
--- a/search/index.html
+++ b/search/index.html
@@ -10,7 +10,41 @@
-
+
+
+
Provide a search query to find packages.
+

+
+
-
+
+
+
+