feat: contextual backlinks (closes #106)
This commit is contained in:
		
							parent
							
								
									6e6dd4cb0b
								
							
						
					
					
						commit
						cea0f3eb74
					
				
					 7 changed files with 101 additions and 66 deletions
				
			
		
							
								
								
									
										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,14 +5,31 @@ 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 => {
 | 
				
			||||||
 | 
					          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, "")]
 | 
					            const linkDest = content[li.dataset.src.replace(/\/$/g, "").replace(basePath, "")]
 | 
				
			||||||
            if (linkDest) {
 | 
					            if (linkDest) {
 | 
				
			||||||
              const popoverElement = `<div class="popover">
 | 
					              const popoverElement = `<div class="popover">
 | 
				
			||||||
| 
						 | 
					@ -29,6 +46,7 @@ function initPopover(baseURL) {
 | 
				
			||||||
                el.classList.remove("visible")
 | 
					                el.classList.remove("visible")
 | 
				
			||||||
              })
 | 
					              })
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -52,40 +52,21 @@ const removeMarkdown = (
 | 
				
			||||||
    return markdown
 | 
					    return markdown
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  return output
 | 
					  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 highlight = (content, term) => {
 | 
				
			||||||
  const highlightWindow = 20
 | 
					  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 tokenizedTerm = term.split(/\s+/).filter((t) => t !== '')
 | 
				
			||||||
  const splitText = content.split(/\s+/).filter((t) => t !== '')
 | 
					  const splitText = content.split(/\s+/).filter((t) => t !== '')
 | 
				
			||||||
  const includesCheck = (token) =>
 | 
					  const includesCheck = (token) =>
 | 
				
			||||||
| 
						 | 
					@ -128,6 +109,35 @@ const removeMarkdown = (
 | 
				
			||||||
    .replaceAll('</span> <span class="search-highlight">', ' ')
 | 
					    .replaceAll('</span> <span class="search-highlight">', ' ')
 | 
				
			||||||
  return `${startIndex === 0 ? '' : '...'}${mappedText}${endIndex === splitText.length ? '' : '...'
 | 
					  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 }) => {
 | 
					  const resultToHTML = ({ url, title, content, term }) => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -478,16 +478,16 @@ header {
 | 
				
			||||||
        & > h3, & > p {
 | 
					        & > h3, & > p {
 | 
				
			||||||
          margin: 0;
 | 
					          margin: 0;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        & .search-highlight {
 | 
					.search-highlight {
 | 
				
			||||||
  background-color: #afbfc966;
 | 
					  background-color: #afbfc966;
 | 
				
			||||||
  padding: 0.05em 0.2em;
 | 
					  padding: 0.05em 0.2em;
 | 
				
			||||||
  border-radius: 3px;
 | 
					  border-radius: 3px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
.section-ul {
 | 
					.section-ul {
 | 
				
			||||||
  list-style: none;
 | 
					  list-style: none;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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…
	
	Add table
		Add a link
		
	
		Reference in a new issue