feat: contextual backlinks (closes #106)
This commit is contained in:
parent
a1d03a044e
commit
81f30fea4e
2
.github/workflows/deploy.yaml
vendored
2
.github/workflows/deploy.yaml
vendored
@ -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
|
||||||
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -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)
|
||||||
|
@ -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;
|
||||||
|
@ -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.
|
||||||
|
@ -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 -}}
|
||||||
|
@ -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}}
|
Loading…
Reference in New Issue
Block a user