feat: contextual backlinks (closes #106)
| | |
| | | fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod |
| | | |
| | | - name: Build Link Index |
| | | uses: jackyzha0/hugo-obsidian@v2.12 |
| | | uses: jackyzha0/hugo-obsidian@v2.13 |
| | | with: |
| | | index: true |
| | | input: content |
| | |
| | | return template.content.firstChild |
| | | } |
| | | |
| | | function initPopover(baseURL) { |
| | | function initPopover(baseURL, useContextualBacklinks) { |
| | | const basePath = baseURL.replace(window.location.origin, "") |
| | | document.addEventListener("DOMContentLoaded", () => { |
| | | fetchData.then(({ content }) => { |
| | | const links = [...document.getElementsByClassName("internal-link")] |
| | | links |
| | | .filter(li => li.dataset.src) |
| | | .filter(li => li.dataset.src || (li.dataset.idx && useContextualBacklinks)) |
| | | .forEach(li => { |
| | | if (li.dataset.ctx) { |
| | | console.log(li.dataset.ctx) |
| | | const linkDest = content[li.dataset.src] |
| | | const popoverElement = `<div class="popover"> |
| | | <h3>${linkDest.title}</h3> |
| | | <p>${highlight(removeMarkdown(linkDest.content), li.dataset.ctx)}...</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") |
| | | }) |
| | | } else { |
| | | const linkDest = content[li.dataset.src.replace(/\/$/g, "").replace(basePath, "")] |
| | | if (linkDest) { |
| | | const popoverElement = `<div class="popover"> |
| | |
| | | el.classList.remove("visible") |
| | | }) |
| | | } |
| | | } |
| | | }) |
| | | }) |
| | | }) |
| | |
| | | return markdown |
| | | } |
| | | return output |
| | | }; |
| | | // ----- |
| | | |
| | | (async function() { |
| | | const encoder = (str) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])+/) |
| | | const contentIndex = new FlexSearch.Document({ |
| | | cache: true, |
| | | charset: 'latin:extra', |
| | | optimize: true, |
| | | index: [ |
| | | { |
| | | field: 'content', |
| | | tokenize: 'reverse', |
| | | encode: encoder, |
| | | }, |
| | | { |
| | | field: 'title', |
| | | tokenize: 'forward', |
| | | encode: encoder, |
| | | }, |
| | | ], |
| | | }) |
| | | |
| | | const { content } = await fetchData |
| | | for (const [key, value] of Object.entries(content)) { |
| | | contentIndex.add({ |
| | | id: key, |
| | | title: value.title, |
| | | content: removeMarkdown(value.content), |
| | | }) |
| | | } |
| | | // ----- |
| | | |
| | | 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) => |
| | |
| | | .replaceAll('</span> <span class="search-highlight">', ' ') |
| | | return `${startIndex === 0 ? '' : '...'}${mappedText}${endIndex === splitText.length ? '' : '...' |
| | | }` |
| | | }; |
| | | |
| | | (async function() { |
| | | const encoder = (str) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])+/) |
| | | const contentIndex = new FlexSearch.Document({ |
| | | cache: true, |
| | | charset: 'latin:extra', |
| | | optimize: true, |
| | | index: [ |
| | | { |
| | | field: 'content', |
| | | tokenize: 'reverse', |
| | | encode: encoder, |
| | | }, |
| | | { |
| | | field: 'title', |
| | | tokenize: 'forward', |
| | | encode: encoder, |
| | | }, |
| | | ], |
| | | }) |
| | | |
| | | const { content } = await fetchData |
| | | for (const [key, value] of Object.entries(content)) { |
| | | contentIndex.add({ |
| | | id: key, |
| | | title: value.title, |
| | | content: removeMarkdown(value.content), |
| | | }) |
| | | } |
| | | |
| | | const resultToHTML = ({ url, title, content, term }) => { |
| | |
| | | & > h3, & > p { |
| | | margin: 0; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | & .search-highlight { |
| | | .search-highlight { |
| | | background-color: #afbfc966; |
| | | padding: 0.05em 0.2em; |
| | | border-radius: 3px; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .section-ul { |
| | | list-style: none; |
| | |
| | | enableLinkPreview: true |
| | | enableLatex: true |
| | | enableSPA: false |
| | | enableContextualBacklinks: true |
| | | description: |
| | | 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. |
| | |
| | | {{$inbound := index $linkIndex.index.backlinks $curPage}} |
| | | {{$contentTable := getJSON "/assets/indices/contentIndex.json"}} |
| | | {{if $inbound}} |
| | | {{$cleanedInbound := apply (apply $inbound "index" "." "source") "replace" "." " " "-"}} |
| | | {{- range $cleanedInbound | uniq -}} |
| | | {{$l := printf "%s%s/" $host .}} |
| | | {{$backlinks := dict "SENTINEL" "SENTINEL"}} |
| | | {{range $k, $v := $inbound}} |
| | | {{$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}} |
| | | {{with (index $contentTable .)}} |
| | | {{with (index $contentTable $lnk)}} |
| | | <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> |
| | | {{end}} |
| | | {{- end -}} |
| | |
| | | {{ $js := resources.Get "js/popover.js" | resources.Fingerprint "md5" | resources.Minify }} |
| | | <script src="{{ $js.Permalink }}"></script> |
| | | <script> |
| | | initPopover({{strings.TrimRight "/" .Site.BaseURL }}) |
| | | const useContextual = {{ $.Site.Data.config.enableContextualBacklinks }} |
| | | initPopover({{strings.TrimRight "/" .Site.BaseURL }}, useContextual) |
| | | </script> |
| | | {{end}} |