add search support

This commit is contained in:
jackyzha0 2021-08-27 14:08:11 -04:00
parent a0ff5ec48c
commit 1c851271ea
10 changed files with 367 additions and 60 deletions

View File

@ -12,8 +12,9 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Build Link Index - name: Build Link Index
uses: jackyzha0/hugo-obsidian@v2.1 uses: jackyzha0/hugo-obsidian@v2.3
with: with:
index: true
input: content input: content
output: data output: data

3
.gitignore vendored
View File

@ -3,4 +3,5 @@ public
resources resources
.idea .idea
content/.obsidian content/.obsidian
data/linkIndex.yaml data/linkIndex.yaml
data/contentIndex.yaml

View File

@ -235,4 +235,120 @@ a[href^="/"] {
.centered { .centered {
margin-top: 30vh; margin-top: 30vh;
}
header {
display: flex;
flex-direction: row;
align-items: center;
& > nav {
@media all and (max-width: 600px) {
display: none;
}
& > a {
margin-left: 2em;
}
}
& > .spacer {
flex: 1 1 auto;
}
& > svg {
cursor: pointer;
width: 18px;
min-width: 18px;
margin: 0 1em;
&:hover .search-path {
stroke: var(--tertiary);
}
.search-path {
stroke: var(--gray);
stroke-width: 2px;
transition: stroke 0.5s ease;
}
}
}
#search-container {
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
display: none;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
& > div {
width: 50%;
margin-top: 15vh;
margin-left: auto;
margin-right: auto;
@media all and (max-width: 1200px) {
width: 90%;
}
& > * {
width: 100%;
border-radius: 4px;
background: var(--light);
box-shadow: 0 14px 50px rgba(27, 33, 48, 0.12), 0 10px 30px rgba(27, 33, 48, 0.16);
margin-bottom: 2em;
}
& > input {
box-sizing: border-box;
padding: 0.5em 1em;
font-family: Inter, sans-serif;
color: var(--dark);
font-size: 1.1em;
border: 1px solid var(--outlinegray);
&:focus {
outline: none;
}
}
& > #results-container {
& > .result-card {
padding: 1em;
cursor: pointer;
transition: background 0.2s ease;
border: 1px solid var(--outlinegray);
border-bottom: none;
&:hover {
background: rgba(180, 180, 180, 0.15);
}
&:first-of-type {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&:last-of-type {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
border-bottom: 1px solid var(--outlinegray);
}
& > h3, & > p {
margin: 0;
}
& .search-highlight {
background-color: #afbfc966;
padding: 0.05em 0.2em;
border-radius: 3px;
}
}
}
}
} }

View File

@ -1,67 +1,44 @@
.darkmode { .darkmode {
text-align: right; float: right;
padding: 1em;
min-width: 30px;
position: relative;
@media all and (max-width: 450px) {
padding: 1em;
}
& > .toggle { & > .toggle {
display: none; display: none;
box-sizing: border-box; box-sizing: border-box;
&:checked + .toggle-button:after {
left: 50%;
}
& + .toggle-button {
box-sizing: border-box;
outline: 0;
display: inline-block;
width: 3em;
height: 1.5em;
position: relative;
cursor: pointer;
border: 2px solid var(--gray);
user-select: none;
padding: 2px;
transition: all 0.2s ease;
border-radius: 2em;
&:after, &:before {
position: relative;
display: block;
box-sizing: border-box;
content: "";
width: 50%;
height: 100%;
}
&:before {
display: none;
}
&:after {
left: 0;
transition: all 0.2s ease;
background: var(--gray);
content: "";
border-radius: 1em;
}
}
} }
& #dayIcon { & svg {
position: relative; opacity: 0;
position: absolute;
width: 20px; width: 20px;
height: 20px; height: 20px;
top: -1.5px; top: calc(50% - 10px);
margin: 0 7px; margin: 0 7px;
fill: var(--gray); fill: var(--gray);
transition: opacity 0.1s ease;
} }
}
& #nightIcon { .toggle:checked ~ label {
position: relative; & > #dayIcon {
width: 18px; opacity: 0;
height: 18px; }
top: -2px; & > #nightIcon {
margin: 0 7px; opacity: 1;
fill: var(--gray); }
}
.toggle:not(:checked) ~ label {
& > #dayIcon {
opacity: 1;
}
& > #nightIcon {
opacity: 0;
} }
} }

View File

@ -1,5 +1,5 @@
# 🌱 Quartz # 🌱 Quartz
## v1.1 ## v2.0
Simple second brain and [digital garden](https://jzhao.xyz/posts/digital-gardening). Simple second brain and [digital garden](https://jzhao.xyz/posts/digital-gardening).

View File

@ -15,7 +15,7 @@ $ go install github.com/jackyzha0/hugo-obsidian
$ cd <location-of-your-local-quartz> $ cd <location-of-your-local-quartz>
# Scrape all links in your Quartz folder and generate info for Quartz # Scrape all links in your Quartz folder and generate info for Quartz
$ hugo-obsidian -input=content -output=data $ hugo-obsidian -input=content -output=data -index=true
``` ```
Afterwards, start the Hugo server as shown above and your local backlinks and interactive graph should be populated! Afterwards, start the Hugo server as shown above and your local backlinks and interactive graph should be populated!

View File

@ -3,11 +3,16 @@
{{ partial "head.html" . }} {{ partial "head.html" . }}
<body> <body>
{{partial "search.html" .}}
<div class="singlePage"> <div class="singlePage">
<!-- Begin actual content --> <!-- Begin actual content -->
{{partial "darkmode.html" .}} <header>
<article>
{{if .Title}}<h1>{{ .Title }}</h1>{{end}} {{if .Title}}<h1>{{ .Title }}</h1>{{end}}
<svg tabindex="0" id="search-icon" aria-labelledby="title desc" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.9 19.7"><title id="title">Search Icon</title><desc id="desc">Icon to open search</desc><g class="search-path" fill="none"><path stroke-linecap="square" d="M18.5 18.3l-5.4-5.4"/><circle cx="8" cy="8" r="7"/></g></svg>
<div class="spacer"></div>
{{partial "darkmode.html" .}}
</header>
<article>
{{if $.Site.Data.config.enableToc}} {{if $.Site.Data.config.enableToc}}
<aside class="mainTOC"> <aside class="mainTOC">
<h3>Table of Contents</h3> <h3>Table of Contents</h3>

View File

@ -1,12 +1,11 @@
<div class='darkmode'> <div class='darkmode'>
<input class='toggle' id='darkmode-toggle' type='checkbox' tabindex="-1">
<label id="toggle-label-light" for='darkmode-toggle' tabindex="-1"> <label id="toggle-label-light" for='darkmode-toggle' tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="dayIcon" x="0px" y="0px" viewBox="0 0 35 35" style="enable-background:new 0 0 35 35;" xml:space="preserve"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="dayIcon" x="0px" y="0px" viewBox="0 0 35 35" style="enable-background:new 0 0 35 35;" xml:space="preserve">
<title>Light Mode</title> <title>Light Mode</title>
<path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z" /> <path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z" />
</svg> </svg>
</label> </label>
<input class='toggle' id='darkmode-toggle' type='checkbox' tabindex="-1">
<label class='toggle-button' for='darkmode-toggle'></label>
<label id="toggle-label-dark" for='darkmode-toggle' tabindex="-1"> <label id="toggle-label-dark" for='darkmode-toggle' tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="nightIcon" x="0px" y="0px" viewBox="0 0 100 100" style="enable-background='new 0 0 100 100'" xml:space="preserve"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="nightIcon" x="0px" y="0px" viewBox="0 0 100 100" style="enable-background='new 0 0 100 100'" xml:space="preserve">
<title>Dark Mode</title> <title>Dark Mode</title>

View File

@ -0,0 +1,208 @@
<div id="search-container">
<div>
<input autocomplete="off" id="search-bar" name="search" type="text" aria-label="Search" placeholder="Search for something...">
<div id="results-container">
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/gh/nextapps-de/flexsearch@0.7.2/dist/flexsearch.bundle.js"></script>
<script>
// code from https://github.com/danestves/markdown-to-text
const removeMarkdown = (
markdown,
options = {
listUnicodeChar: false,
stripListLeaders: true,
gfm: true,
useImgAltText: false,
preserveLinks: false,
}
) => {
let output = markdown || "";
output = output.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, "");
try {
if (options.stripListLeaders) {
if (options.listUnicodeChar)
output = output.replace(
/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm,
options.listUnicodeChar + " $1"
);
else output = output.replace(/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, "$1");
}
if (options.gfm) {
output = output
.replace(/\n={2,}/g, "\n")
.replace(/~{3}.*\n/g, "")
.replace(/~~/g, "")
.replace(/`{3}.*\n/g, "");
}
if(options.preserveLinks) {
output = output.replace(/\[(.*?)\][\[\(](.*?)[\]\)]/g, "$1 ($2)")
}
output = output
.replace(/<[^>]*>/g, "")
.replace(/^[=\-]{2,}\s*$/g, "")
.replace(/\[\^.+?\](\: .*?$)?/g, "")
.replace(/\s{0,2}\[.*?\]: .*?$/g, "")
.replace(/\!\[(.*?)\][\[\(].*?[\]\)]/g, options.useImgAltText ? "$1" : "")
.replace(/\[(.*?)\][\[\(].*?[\]\)]/g, "$1")
.replace(/^\s{0,3}>\s?/g, "")
.replace(/(^|\n)\s{0,3}>\s?/g, "\n\n")
.replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, "")
.replace(
/^(\n)?\s{0,}#{1,6}\s+| {0,}(\n)?\s{0,}#{0,} {0,}(\n)?\s{0,}$/gm,
"$1$2$3"
)
.replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2")
.replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2")
.replace(/(`{3,})(.*?)\1/gm, "$2")
.replace(/`(.+?)`/g, "$1")
.replace(/\n{2,}/g, "\n\n");
} catch (e) {
console.error(e);
return markdown;
}
return output;
};
</script>
<script>
const contentIndex = new FlexSearch.Worker({
tokenize: "strict",
charset: "latin:advanced",
context: true,
depth: 3,
cache: 10,
suggest: true,
})
const scrapedContent = {{$.Site.Data.contentIndex}}
for (const [key, value] of Object.entries(scrapedContent)) {
contentIndex.add(key, value.content)
}
const stopwords = ['i','me','my','myself','we','our','ours','ourselves','you','your','yours','yourself','yourselves','he','him','his','himself','she','her','hers','herself','it','its','itself','they','them','their','theirs','themselves','what','which','who','whom','this','that','these','those','am','is','are','was','were','be','been','being','have','has','had','having','do','does','did','doing','a','an','the','and','but','if','or','because','as','until','while','of','at','by','for','with','about','against','between','into','through','during','before','after','above','below','to','from','up','down','in','out','on','off','over','under','again','further','then','once','here','there','when','where','why','how','all','any','both','each','few','more','most','other','some','such','no','nor','not','only','own','same','so','than','too','very','s','t','can','will','just','don','should','now']
const highlight = (content, term) => {
const highlightWindow = 15
const tokenizedTerm = term.split(/\s+/).filter(t => t !== "")
const splitText = content.split(/\s+/).filter(t => t !== "")
const includesCheck = (token) => tokenizedTerm.some(term => token.toLowerCase().includes(term.toLowerCase()))
const occurrencesIndices = splitText
.map(includesCheck)
// calculate best index
let bestSum = 0
let bestIndex = 0
for (let i = 0; i < Math.max(occurrencesIndices.length - highlightWindow, 0); i++) {
const window = occurrencesIndices.slice(i, i + highlightWindow)
const windowSum = window.reduce((total, cur) => total + cur, 0)
if (windowSum > bestSum) {
bestSum = windowSum
bestIndex = i
}
}
const startIndex = Math.max(bestIndex - highlightWindow, 0)
const endIndex = Math.min(startIndex + 2 * highlightWindow, splitText.length)
const mappedText = splitText
.slice(startIndex, endIndex)
.map(token => {
if (includesCheck(token)) {
return `<span class="search-highlight">${token}</span>`
}
return token
})
.join(" ")
.replaceAll('</span> <span class="search-highlight">', " ")
return `${startIndex === 0 ? "" : "..."}${mappedText}${endIndex === splitText.length ? "" : "..."}`
}
const resultToHTML = ({url, title, content, term}) => {
const md = content.split("---")[2]
const text = removeMarkdown(md)
const resultTitle = highlight(title, term)
const resultText = highlight(text, term)
return `<div class="result-card" id="${url}">
<h3>${resultTitle}</h3>
<p>${resultText}</p>
</div>`
}
const source = document.getElementById('search-bar')
const results = document.getElementById("results-container")
source.addEventListener('input', (e) => {
const term = e.target.value
contentIndex.search(term, {
limit: 5,
depth: 3,
suggest: true,
}).then(searchResults => {
const resultIds = [...new Set(searchResults)]
const finalResults = resultIds.map(id => ({
url: id,
title: scrapedContent[id].title,
content: scrapedContent[id].content
}))
// display
if (finalResults.length === 0) {
results.innerHTML = `<div class="result-card">
<p>No results.</p>
</div>`
} else {
results.innerHTML = finalResults
.map(result => resultToHTML({
...result,
term,
}))
.join("\n")
const anchors = document.getElementsByClassName("result-card");
[...anchors].forEach(anchor => {
anchor.onclick = () => {
window.location.href = `${anchor.id}#:~:text=${encodeURIComponent(term)}`
}
})
}
})
})
const searchContainer = document.getElementById("search-container")
function openSearch() {
if (searchContainer.style.display === "none" || searchContainer.style.display === "") {
source.value = ""
results.innerHTML = ""
searchContainer.style.display = "block"
source.focus()
} else {
searchContainer.style.display = "none"
}
}
function closeSearch() {
searchContainer.style.display = "none"
}
document.addEventListener('keydown', (event) => {
if (event.key === "/") {
event.preventDefault()
openSearch()
}
if (event.key === "Escape") {
event.preventDefault()
closeSearch()
}
})
window.addEventListener('DOMContentLoaded', () => {
const searchButton = document.getElementById("search-icon")
searchButton.addEventListener('click', (evt) => {
openSearch()
})
searchButton.addEventListener('keydown', (evt) => {
openSearch()
})
})
</script>