feat: contextual backlinks (closes #106)

This commit is contained in:
Jacky Zhao 2022-05-05 00:58:50 -04:00
parent 6e6dd4cb0b
commit cea0f3eb74
7 changed files with 101 additions and 66 deletions

View file

@ -14,7 +14,7 @@ jobs:
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
- name: Build Link Index - name: Build Link Index
uses: jackyzha0/hugo-obsidian@v2.12 uses: jackyzha0/hugo-obsidian@v2.13
with: with:
index: true index: true
input: content input: content

View file

@ -5,19 +5,20 @@ function htmlToElement(html) {
return template.content.firstChild return template.content.firstChild
} }
function initPopover(baseURL) { function initPopover(baseURL, useContextualBacklinks) {
const basePath = baseURL.replace(window.location.origin, "") const basePath = baseURL.replace(window.location.origin, "")
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
fetchData.then(({ content }) => { fetchData.then(({ content }) => {
const links = [...document.getElementsByClassName("internal-link")] const links = [...document.getElementsByClassName("internal-link")]
links links
.filter(li => li.dataset.src) .filter(li => li.dataset.src || (li.dataset.idx && useContextualBacklinks))
.forEach(li => { .forEach(li => {
const linkDest = content[li.dataset.src.replace(/\/$/g, "").replace(basePath, "")] if (li.dataset.ctx) {
if (linkDest) { console.log(li.dataset.ctx)
const linkDest = content[li.dataset.src]
const popoverElement = `<div class="popover"> const popoverElement = `<div class="popover">
<h3>${linkDest.title}</h3> <h3>${linkDest.title}</h3>
<p>${removeMarkdown(linkDest.content).split(" ", 20).join(" ")}...</p> <p>${highlight(removeMarkdown(linkDest.content), li.dataset.ctx)}...</p>
<p class="meta">${new Date(linkDest.lastmodified).toLocaleDateString()}</p> <p class="meta">${new Date(linkDest.lastmodified).toLocaleDateString()}</p>
</div>` </div>`
const el = htmlToElement(popoverElement) const el = htmlToElement(popoverElement)
@ -28,6 +29,23 @@ function initPopover(baseURL) {
li.addEventListener("mouseout", () => { li.addEventListener("mouseout", () => {
el.classList.remove("visible") el.classList.remove("visible")
}) })
} else {
const linkDest = content[li.dataset.src.replace(/\/$/g, "").replace(basePath, "")]
if (linkDest) {
const popoverElement = `<div class="popover">
<h3>${linkDest.title}</h3>
<p>${removeMarkdown(linkDest.content).split(" ", 20).join(" ")}...</p>
<p class="meta">${new Date(linkDest.lastmodified).toLocaleDateString()}</p>
</div>`
const el = htmlToElement(popoverElement)
li.appendChild(el)
li.addEventListener("mouseover", () => {
el.classList.add("visible")
})
li.addEventListener("mouseout", () => {
el.classList.remove("visible")
})
}
} }
}) })
}) })

View file

@ -52,9 +52,65 @@ const removeMarkdown = (
return markdown return markdown
} }
return output return output
}; }
// ----- // -----
const highlight = (content, term) => {
const highlightWindow = 20
// try to find direct match first
const directMatchIdx = content.indexOf(term)
if (directMatchIdx !== -1) {
const h = highlightWindow / 2
const before = content.substring(0, directMatchIdx).split(" ").slice(-h)
const after = content.substring(directMatchIdx + term.length, content.length - 1).split(" ").slice(0, h)
return (before.length == h ? `...${before.join(" ")}` : before.join(" ")) + `<span class="search-highlight">${term}</span>` + after.join(" ")
}
const tokenizedTerm = term.split(/\s+/).filter((t) => t !== '')
const splitText = content.split(/\s+/).filter((t) => t !== '')
const includesCheck = (token) =>
tokenizedTerm.some((term) =>
token.toLowerCase().startsWith(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 ? '' : '...'
}`
};
(async function() { (async function() {
const encoder = (str) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])+/) const encoder = (str) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])+/)
const contentIndex = new FlexSearch.Document({ const contentIndex = new FlexSearch.Document({
@ -84,52 +140,6 @@ const removeMarkdown = (
}) })
} }
const highlight = (content, term) => {
const highlightWindow = 20
const tokenizedTerm = term.split(/\s+/).filter((t) => t !== '')
const splitText = content.split(/\s+/).filter((t) => t !== '')
const includesCheck = (token) =>
tokenizedTerm.some((term) =>
token.toLowerCase().startsWith(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 resultToHTML = ({ url, title, content, term }) => {
const text = removeMarkdown(content) const text = removeMarkdown(content)
const resultTitle = highlight(title, term) const resultTitle = highlight(title, term)

View file

@ -478,17 +478,17 @@ header {
& > h3, & > p { & > h3, & > p {
margin: 0; margin: 0;
} }
& .search-highlight {
background-color: #afbfc966;
padding: 0.05em 0.2em;
border-radius: 3px;
}
} }
} }
} }
} }
.search-highlight {
background-color: #afbfc966;
padding: 0.05em 0.2em;
border-radius: 3px;
}
.section-ul { .section-ul {
list-style: none; list-style: none;
padding-left: 0; padding-left: 0;

View file

@ -4,6 +4,7 @@ openToc: false
enableLinkPreview: true enableLinkPreview: true
enableLatex: true enableLatex: true
enableSPA: false enableSPA: false
enableContextualBacklinks: true
description: description:
Host your second brain and digital garden for free. Quartz features extremely fast full-text search, Host your second brain and digital garden for free. Quartz features extremely fast full-text search,
Wikilink support, backlinks, local graph, tags, and link previews. Wikilink support, backlinks, local graph, tags, and link previews.

View file

@ -7,13 +7,18 @@
{{$inbound := index $linkIndex.index.backlinks $curPage}} {{$inbound := index $linkIndex.index.backlinks $curPage}}
{{$contentTable := getJSON "/assets/indices/contentIndex.json"}} {{$contentTable := getJSON "/assets/indices/contentIndex.json"}}
{{if $inbound}} {{if $inbound}}
{{$cleanedInbound := apply (apply $inbound "index" "." "source") "replace" "." " " "-"}} {{$backlinks := dict "SENTINEL" "SENTINEL"}}
{{- range $cleanedInbound | uniq -}} {{range $k, $v := $inbound}}
{{$l := printf "%s%s/" $host .}} {{$cleanedInbound := replace $v.source " " "-"}}
{{$ctx := $v.text}}
{{$backlinks = merge $backlinks (dict $cleanedInbound $ctx)}}
{{end}}
{{- range $lnk, $ctx := $backlinks -}}
{{$l := printf "%s%s/" $host $lnk}}
{{$l = cond (eq $l "//") "/" $l}} {{$l = cond (eq $l "//") "/" $l}}
{{with (index $contentTable .)}} {{with (index $contentTable $lnk)}}
<li> <li>
<a href="{{$l}}">{{index (index . "title")}}</a> <a href="{{$l}}" data-ctx="{{$ctx}}" data-src="{{$lnk}}" class="internal-link">{{index (index . "title")}}</a>
</li> </li>
{{end}} {{end}}
{{- end -}} {{- end -}}

View file

@ -2,6 +2,7 @@
{{ $js := resources.Get "js/popover.js" | resources.Fingerprint "md5" | resources.Minify }} {{ $js := resources.Get "js/popover.js" | resources.Fingerprint "md5" | resources.Minify }}
<script src="{{ $js.Permalink }}"></script> <script src="{{ $js.Permalink }}"></script>
<script> <script>
initPopover({{strings.TrimRight "/" .Site.BaseURL }}) const useContextual = {{ $.Site.Data.config.enableContextualBacklinks }}
initPopover({{strings.TrimRight "/" .Site.BaseURL }}, useContextual)
</script> </script>
{{end}} {{end}}