feat: Search using lunr

Context: having search is almots essential feature for most of the
websites. This commit adds very basic functionality based on lunrjs
library. It allows to build an use search index.

Changes:
- Build search index using 11ty template
- Adds postbuild step to build lunr index
- Adds netlify function to serve as API for search
- Adds search page and links to notes pages
- Wraps hashtags with proper styling and adds search by hashtag link to
  them
- Adds missing obsidian class for content
This commit is contained in:
Mark Orel 2022-10-16 13:56:28 +01:00 committed by Ole Eskild Steensen
parent 052e21193b
commit fd92473178
13 changed files with 188 additions and 3 deletions

View File

@ -184,6 +184,9 @@ module.exports = function(eleventyConfig) {
eleventyConfig.addPassthroughCopy("src/site/img");
eleventyConfig.addPlugin(faviconPlugin, { destination: 'dist' });
eleventyConfig.addFilter('jsonify', function (variable) {
return JSON.stringify(variable);
});
return {
dir: {

1
.eleventyignore Normal file
View File

@ -0,0 +1 @@
netlify/functions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
node_modules
dist
netlify/functions/search/data.json
netlify/functions/search/index.json

View File

@ -2,7 +2,13 @@
publish = "dist"
command = "npm install && npm run build"
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/:splat"
status = 200
[[redirects]]
from = "/*"
to = "/404"
status = 404
status = 404

View File

@ -0,0 +1,45 @@
const lunrjs = require('lunr');
const handler = async (event) => {
try {
const search = event.queryStringParameters.term;
if(!search) throw('Missing term query parameter');
const data = require('./data.json');
const indexJson = require('./index.json');
const index = lunrjs.Index.load(indexJson);
console.log('index made');
let results = index.search(search);
results.forEach(r => {
r.title = data[r.ref].title;
r.content = truncate(data[r.ref].content, 400);
r.date = data[r.ref].date;
r.url = data[r.ref].url;
delete r.ref;
});
return {
statusCode: 200,
body: JSON.stringify(results),
// // more keys you can return:
// headers: { "headerName": "headerValue", ... },
// isBase64Encoded: true,
}
} catch (error) {
return { statusCode: 500, body: error.toString() }
}
}
function truncate(str, size) {
//first, remove HTML
str = str.replace(/<.*?>/g, '');
if(str.length < size) return str;
return str.substring(0, size-3) + '...';
}
module.exports = { handler }

11
package-lock.json generated
View File

@ -17,6 +17,7 @@
"eleventy-favicon": "^1.1.2",
"fs-file-tree": "^1.1.1",
"gray-matter": "^4.0.3",
"lunr": "^2.3.9",
"markdown-it": "^12.3.2",
"markdown-it-footnote": "^3.0.3",
"markdown-it-mathjax3": "^4.3.1",
@ -3332,6 +3333,11 @@
"yallist": "^2.1.2"
}
},
"node_modules/lunr": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="
},
"node_modules/luxon": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-2.3.1.tgz",
@ -8855,6 +8861,11 @@
"yallist": "^2.1.2"
}
},
"lunr": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="
},
"luxon": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-2.3.1.tgz",

View File

@ -9,7 +9,8 @@
"watch:eleventy": "cross-env ELEVENTY_ENV=dev eleventy --serve",
"build:eleventy": "cross-env ELEVENTY_ENV=prod NODE_OPTIONS=--max-old-space-size=4096 eleventy",
"build:sass": "sass src/site/styles:dist/styles",
"build": "npm-run-all build:*"
"build": "npm-run-all build:*",
"postbuild": "node src/site/lunr-index.js"
},
"keywords": [],
"author": "",
@ -29,6 +30,7 @@
"eleventy-favicon": "^1.1.2",
"fs-file-tree": "^1.1.1",
"gray-matter": "^4.0.3",
"lunr": "^2.3.9",
"markdown-it": "^12.3.2",
"markdown-it-footnote": "^3.0.3",
"markdown-it-mathjax3": "^4.3.1",

View File

@ -0,0 +1,8 @@
<script>
document.addEventListener('DOMContentLoaded', init, false);
async function init() {
const content = document.body.querySelector('.content');
content.innerHTML = content.innerHTML.replaceAll(/\s\#(\w+)/gm,
`<span class="cm-formatting cm-formatting-hashtag cm-hashtag cm-hashtag-begin cm-meta">#</span><span class="cm-hashtag cm-hashtag-end cm-meta"><a href="/search?q=%23$1~1">$1</a></span>`);
}
</script>

View File

@ -7,6 +7,7 @@ permalink: "notes/{{ page.fileSlug | slugify }}/"
<head>
<title>{{ page.fileSlug }}</title>
{%include "components/pageheader.njk"%}
{%include "components/wrapTagsScript.njk"%}
</head>
<body class="theme-{{meta.baseTheme}} markdown-preview-view markdown-rendered markdown-preview-section">
{%include "components/notegrowthhistory.njk"%}
@ -27,5 +28,8 @@ permalink: "notes/{{ page.fileSlug | slugify }}/"
{%include "components/sidebar.njk"%}
{% endif %}
</div>
{% if dgShowBacklinks === true or dgShowLocalGraph === true%}
{%include "components/sidebar.njk"%}
{% endif %}
</body>
</html>

View File

@ -26,4 +26,4 @@
{%endif%}
</div>
</body>
</html>
</html>

20
src/site/lunr-index.js Normal file
View File

@ -0,0 +1,20 @@
const lunrjs = require('lunr');
const path = require('path');
function createIndex(posts) {
return lunrjs(function() {
this.ref('id');
this.field('title');
this.field('content');
this.field('date');
posts.forEach((p,idx) => {
p.id = idx;
this.add(p);
});
});
}
const data = require('../../netlify/functions/search/data.json');
const index = createIndex(data);
require('fs').writeFileSync(path.join(__dirname, '../../netlify/functions/search/index.json'), JSON.stringify(index));

12
src/site/lunr.njk Normal file
View File

@ -0,0 +1,12 @@
---
permalink: netlify/functions/search/data.json
permalinkBypassOutputDir: true
---
[{% for post in collections.note %}
{
"title": {{post.fileSlug | jsonify | safe }},
"date":"{{ post.date }}",
"url":"{{ post.url }}",
"content": {{ post.templateContent | striptags(true) | jsonify | safe }}
}{% if not loop.last %},{% endif %}
{% endfor %}]

71
src/site/search.njk Normal file
View File

@ -0,0 +1,71 @@
---
title: "Search"
permalink: "search/"
---
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ collections.gardenEntry[0].fileSlug }}</title>
{%include "components/pageheader.njk"%}
</head>
<body class="theme-{{meta.baseTheme}} markdown-preview-view">
<div class="content cm-s-obsidian">
{% if dgHomeLink !== false%}
<a href="/">🏡 Back Home</a>
{% endif %}
<h1>Search</h1>
<p>
<label for="term">Type search query</label>
<br/>
<input type="search" id="term" placeholder="Start typing...">
</p>
<div id="results"></div>
<script>
document.addEventListener('DOMContentLoaded', init, false);
function debounce(func, timeout = 500){
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => { func.apply(this, args); }, timeout);
};
}
async function init() {
field = document.querySelector('#term');
field.addEventListener('keydown', debounce(() => search()));
resultsDiv = document.querySelector('#results');
const params = new URL(location.href).searchParams;
if (params.get('q')) {
field.setAttribute('value', params.get('q'));
search();
}
}
async function search() {
let search = field.value.trim();
if(!search) return;
console.log(`search for ${search}`);
let searchRequest = await fetch(`/api/search?term=${encodeURIComponent(search)}`);
let results = await searchRequest.json();
let resultsHTML = '<p><strong>Search Results</strong></p>';
if(!results.length) {
resultsHTML += '<p>Sorry, there were no results.</p>';
resultsDiv.innerHTML = resultsHTML;
return;
}
resultsHTML += '<ul>';
// we need to add title, url from ref
results.forEach(r => {
resultsHTML += `<li><a href="${r.url}">${ r.title }</a>&nbsp;<span>${r.content}</span></li>`;
});
resultsHTML += '</ul>';
resultsDiv.innerHTML = resultsHTML;
}
</script>
</div>
</body>
</html>