Jacky Zhao
2022-05-05 cea0f3eb743b26db0d5297ab10e229617585fe0c
feat: contextual backlinks (closes #106)
7 files modified
167 ■■■■■ changed files
.github/workflows/deploy.yaml 2 ●●● patch | view | raw | blame | history
assets/js/popover.js 28 ●●●● patch | view | raw | blame | history
assets/js/search.js 104 ●●●● patch | view | raw | blame | history
assets/styles/base.scss 12 ●●●● patch | view | raw | blame | history
data/config.yaml 1 ●●●● patch | view | raw | blame | history
layouts/partials/backlinks.html 15 ●●●●● patch | view | raw | blame | history
layouts/partials/popover.html 5 ●●●●● patch | view | raw | blame | history
.github/workflows/deploy.yaml
@@ -14,7 +14,7 @@
          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
assets/js/popover.js
@@ -5,19 +5,20 @@
  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 => {
          const linkDest = content[li.dataset.src.replace(/\/$/g, "").replace(basePath, "")]
          if (linkDest) {
          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>${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>
</div>`
            const el = htmlToElement(popoverElement)
@@ -28,6 +29,23 @@
            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">
    <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")
              })
            }
          }
        })
    })
assets/js/search.js
@@ -52,9 +52,65 @@
    return markdown
  }
  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() {
  const encoder = (str) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])+/)
  const contentIndex = new FlexSearch.Document({
@@ -84,52 +140,6 @@
    })
  }
  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 text = removeMarkdown(content)
    const resultTitle = highlight(title, term)
assets/styles/base.scss
@@ -478,17 +478,17 @@
        & > h3, & > p {
          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 {
  list-style: none;
  padding-left: 0;
data/config.yaml
@@ -4,6 +4,7 @@
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.
layouts/partials/backlinks.html
@@ -7,13 +7,18 @@
    {{$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 -}}
layouts/partials/popover.html
@@ -2,6 +2,7 @@
{{ $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}}
{{end}}