diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index a492b9b0..656ef4a7 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -16,7 +16,7 @@ jobs: with: index: true input: content - output: static + output: assets/indices root: . - name: Setup Hugo diff --git a/.gitignore b/.gitignore index 54ae7a3f..a7ccdb59 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,5 @@ public resources .idea content/.obsidian -static/linkIndex.json -static/contentIndex.json \ No newline at end of file +assets/indices/linkIndex.json +assets/indices/contentIndex.json diff --git a/Makefile b/Makefile index 3c6c0aff..038d69e9 100644 --- a/Makefile +++ b/Makefile @@ -4,4 +4,4 @@ help: ## Show all Makefile targets @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' serve: ## serve - hugo-obsidian -input=content -output=static -index -root=. && hugo server + hugo-obsidian -input=content -output=assets/indices -index -root=. && hugo server diff --git a/assets/indices/.gitkeep b/assets/indices/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/assets/darkmode.js b/assets/js/darkmode.js similarity index 100% rename from assets/darkmode.js rename to assets/js/darkmode.js diff --git a/assets/js/graph.js b/assets/js/graph.js new file mode 100644 index 00000000..c6c87185 --- /dev/null +++ b/assets/js/graph.js @@ -0,0 +1,220 @@ +async function drawGraph(url, baseUrl, pathColors, depth, enableDrag, enableLegend, enableZoom) { + const { index, links, content } = await fetchData + const curPage = url.replace(baseUrl, "") + + const parseIdsFromLinks = (links) => [...(new Set(links.flatMap(link => ([link.source, link.target]))))] + + const neighbours = new Set() + const wl = [curPage || "/", "__SENTINEL"] + if (depth >= 0) { + while (depth >= 0 && wl.length > 0) { + // compute neighbours + const cur = wl.shift() + if (cur === "__SENTINEL") { + depth-- + wl.push("__SENTINEL") + } else { + neighbours.add(cur) + const outgoing = index.links[cur] || [] + const incoming = index.backlinks[cur] || [] + wl.push(...outgoing.map(l => l.target), ...incoming.map(l => l.source)) + } + } + } else { + parseIdsFromLinks(links).forEach(id => neighbours.add(id)) + } + + const data = { + nodes: [...neighbours].map(id => ({ id })), + links: links.filter(l => neighbours.has(l.source) && neighbours.has(l.target)), + } + + const color = (d) => { + if (d.id === curPage || (d.id === "/" && curPage === "")) { + return "var(--g-node-active)" + } + + for (const pathColor of pathColors) { + const path = Object.keys(pathColor)[0] + const colour = pathColor[path] + if (d.id.startsWith(path)) { + return colour + } + } + + return "var(--g-node)" + } + + const drag = simulation => { + function dragstarted(event, d) { + if (!event.active) simulation.alphaTarget(1).restart(); + d.fx = d.x; + d.fy = d.y; + } + + function dragged(event, d) { + d.fx = event.x; + d.fy = event.y; + } + + function dragended(event, d) { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + } + + const noop = () => { } + return d3.drag() + .on("start", enableDrag ? dragstarted : noop) + .on("drag", enableDrag ? dragged : noop) + .on("end", enableDrag ? dragended : noop); + } + + const height = 250 + const width = document.getElementById("graph-container").offsetWidth + + const simulation = d3.forceSimulation(data.nodes) + .force("charge", d3.forceManyBody().strength(-30)) + .force("link", d3.forceLink(data.links).id(d => d.id)) + .force("center", d3.forceCenter()); + + const svg = d3.select('#graph-container') + .append('svg') + .attr('width', width) + .attr('height', height) + .attr("viewBox", [-width / 2, -height / 2, width, height]); + + if (enableLegend) { + const legend = [ + { "Current": "var(--g-node-active)" }, + { "Note": "var(--g-node)" }, + ...pathColors + ] + legend.forEach((legendEntry, i) => { + const key = Object.keys(legendEntry)[0] + const colour = legendEntry[key] + svg.append("circle").attr("cx", -width / 2 + 20).attr("cy", height / 2 - 30 * (i + 1)).attr("r", 6).style("fill", colour) + svg.append("text").attr("x", -width / 2 + 40).attr("y", height / 2 - 30 * (i + 1)).text(key).style("font-size", "15px").attr("alignment-baseline", "middle") + }) + } + + // draw links between nodes + const link = svg.append("g") + .selectAll("line") + .data(data.links) + .join("line") + .attr("class", "link") + .attr("stroke", "var(--g-link)") + .attr("stroke-width", 2) + .attr("data-source", d => d.source.id) + .attr("data-target", d => d.target.id) + + // svg groups + const graphNode = svg.append("g") + .selectAll("g") + .data(data.nodes) + .enter().append("g") + + // draw individual nodes + const node = graphNode.append("circle") + .attr("class", "node") + .attr("id", (d) => d.id) + .attr("r", (d) => { + const numOut = index.links[d.id]?.length || 0 + const numIn = index.backlinks[d.id]?.length || 0 + return 3 + (numOut + numIn) / 4 + }) + .attr("fill", color) + .style("cursor", "pointer") + .on("click", (_, d) => { + window.location.href = baseUrl + '/' + decodeURI(d.id).replace(/\s+/g, '-') + }) + .on("mouseover", function (_, d) { + d3.selectAll(".node") + .transition() + .duration(100) + .attr("fill", "var(--g-node-inactive)") + + const neighbours = parseIdsFromLinks([...(index.links[d.id] || []), ...(index.backlinks[d.id] || [])]) + const neighbourNodes = d3.selectAll(".node").filter(d => neighbours.includes(d.id)) + const currentId = d.id + const linkNodes = d3.selectAll(".link").filter(d => d.source.id === currentId || d.target.id === currentId) + + // highlight neighbour nodes + neighbourNodes + .transition() + .duration(200) + .attr("fill", color) + + // highlight links + linkNodes + .transition() + .duration(200) + .attr("stroke", "var(--g-link-active)") + + // show text for self + d3.select(this.parentNode) + .select("text") + .raise() + .transition() + .duration(200) + .style("opacity", 1) + }).on("mouseleave", function (_, d) { + d3.selectAll(".node") + .transition() + .duration(200) + .attr("fill", color) + + const currentId = d.id + const linkNodes = d3.selectAll(".link").filter(d => d.source.id === currentId || d.target.id === currentId) + + linkNodes + .transition() + .duration(200) + .attr("stroke", "var(--g-link)") + + d3.select(this.parentNode) + .select("text") + .transition() + .duration(200) + .style("opacity", 0) + }) + .call(drag(simulation)); + + // draw labels + const labels = graphNode.append("text") + .attr("dx", 12) + .attr("dy", ".35em") + .text((d) => content[decodeURI(d.id).replace(/\s+/g, '-')]?.title || "Untitled") + .style("opacity", 0) + .style("pointer-events", "none") + .call(drag(simulation)); + + // set panning + + if (enableZoom) { + svg.call(d3.zoom() + .extent([[0, 0], [width, height]]) + .scaleExtent([0.25, 4]) + .on("zoom", ({ transform }) => { + link.attr("transform", transform); + node.attr("transform", transform); + labels.attr("transform", transform); + })); + } + + // progress the simulation + simulation.on("tick", () => { + link + .attr("x1", d => d.source.x) + .attr("y1", d => d.source.y) + .attr("x2", d => d.target.x) + .attr("y2", d => d.target.y) + node + .attr("cx", d => d.x) + .attr("cy", d => d.y) + labels + .attr("x", d => d.x) + .attr("y", d => d.y) + }); +} diff --git a/assets/js/popover.js b/assets/js/popover.js new file mode 100644 index 00000000..cf6f84b7 --- /dev/null +++ b/assets/js/popover.js @@ -0,0 +1,34 @@ +function htmlToElement(html) { + const template = document.createElement('template') + html = html.trim() + template.innerHTML = html + return template.content.firstChild +} + +function initPopover(baseURL) { + const basePath = baseURL.replace(window.location.origin, "") + document.addEventListener("DOMContentLoaded", () => { + fetchData.then(({ content }) => { + const links = [...document.getElementsByClassName("internal-link")] + links.forEach(li => { + const linkDest = content[li.dataset.src.replace(basePath, "")] + // const linkDest = content[li.dataset.src] + if (linkDest) { + const popoverElement = `
+

${linkDest.title}

+

${removeMarkdown(linkDest.content).split(" ", 20).join(" ")}...

+

${new Date(linkDest.lastmodified).toLocaleDateString()}

+
` + const el = htmlToElement(popoverElement) + li.appendChild(el) + li.addEventListener("mouseover", () => { + el.classList.add("visible") + }) + li.addEventListener("mouseout", () => { + el.classList.remove("visible") + }) + } + }) + }) + }) +} diff --git a/assets/js/search.js b/assets/js/search.js new file mode 100644 index 00000000..34b7b621 --- /dev/null +++ b/assets/js/search.js @@ -0,0 +1,247 @@ +// code from https://github.com/danestves/markdown-to-text +const removeMarkdown = ( + markdown, + options = { + listUnicodeChar: false, + stripListLeaders: true, + gfm: true, + useImgAltText: false, + preserveLinks: false, + } +) => { + let output = markdown || ""; + output = output.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, ""); + + try { + if (options.stripListLeaders) { + if (options.listUnicodeChar) + output = output.replace( + /^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, + options.listUnicodeChar + " $1" + ); + else output = output.replace(/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, "$1"); + } + if (options.gfm) { + output = output + .replace(/\n={2,}/g, "\n") + .replace(/~{3}.*\n/g, "") + .replace(/~~/g, "") + .replace(/`{3}.*\n/g, ""); + } + if (options.preserveLinks) { + output = output.replace(/\[(.*?)\][\[\(](.*?)[\]\)]/g, "$1 ($2)") + } + output = output + .replace(/<[^>]*>/g, "") + .replace(/^[=\-]{2,}\s*$/g, "") + .replace(/\[\^.+?\](\: .*?$)?/g, "") + .replace(/\s{0,2}\[.*?\]: .*?$/g, "") + .replace(/\!\[(.*?)\][\[\(].*?[\]\)]/g, options.useImgAltText ? "$1" : "") + .replace(/\[(.*?)\][\[\(].*?[\]\)]/g, "$1") + .replace(/^\s{0,3}>\s?/g, "") + .replace(/(^|\n)\s{0,3}>\s?/g, "\n\n") + .replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, "") + .replace( + /^(\n)?\s{0,}#{1,6}\s+| {0,}(\n)?\s{0,}#{0,} {0,}(\n)?\s{0,}$/gm, + "$1$2$3" + ) + .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2") + .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2") + .replace(/(`{3,})(.*?)\1/gm, "$2") + .replace(/`(.+?)`/g, "$1") + .replace(/\n{2,}/g, "\n\n"); + } catch (e) { + console.error(e); + return markdown; + } + return output; +}; +// ----- + +(async function () { + const contentIndex = new FlexSearch.Document({ + cache: true, + charset: "latin:extra", + optimize: true, + worker: true, + document: { + index: [{ + field: "content", + tokenize: "strict", + context: { + resolution: 5, + depth: 3, + bidirectional: true + }, + suggest: true, + }, { + field: "title", + tokenize: "forward", + }] + } + }) + + 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 + 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 `${token}` + } + return token + }) + .join(" ") + .replaceAll(' ', " ") + return `${startIndex === 0 ? "" : "..."}${mappedText}${endIndex === splitText.length ? "" : "..."}` + } + + const resultToHTML = ({ url, title, content, term }) => { + const text = removeMarkdown(content) + const resultTitle = highlight(title, term) + const resultText = highlight(text, term) + return `` + } + + const redir = (id, term) => { + window.location.href = BASE_URL + `${id}#:~:text=${encodeURIComponent(term)}` + } + + const formatForDisplay = id => ({ + id, + url: id, + title: content[id].title, + content: content[id].content + }) + + const source = document.getElementById('search-bar') + const results = document.getElementById("results-container") + let term + source.addEventListener("keyup", (e) => { + if (e.key === "Enter") { + const anchor = document.getElementsByClassName("result-card")[0] + redir(anchor.id, term) + } + }) + source.addEventListener('input', (e) => { + term = e.target.value + contentIndex.search(term, [ + { + field: "content", + limit: 10, + suggest: true, + }, + { + field: "title", + limit: 5, + } + ]).then(searchResults => { + const getByField = field => { + const results = searchResults.filter(x => x.field === field) + if (results.length === 0) { + return [] + } else { + return [...results[0].result] + } + } + const allIds = new Set([...getByField('title'), ...getByField('content')]) + const finalResults = [...allIds].map(formatForDisplay) + + // display + if (finalResults.length === 0) { + results.innerHTML = `` + } else { + results.innerHTML = finalResults + .map(result => resultToHTML({ + ...result, + term, + })) + .join("\n") + const anchors = document.getElementsByClassName("result-card"); + [...anchors].forEach(anchor => { + anchor.onclick = () => redir(anchor.id, term) + }) + } + }) + }) + + + const searchContainer = document.getElementById("search-container") + + function openSearch() { + if (searchContainer.style.display === "none" || searchContainer.style.display === "") { + source.value = "" + results.innerHTML = "" + searchContainer.style.display = "block" + source.focus() + } else { + searchContainer.style.display = "none" + } + } + + function closeSearch() { + searchContainer.style.display = "none" + } + + document.addEventListener('keydown', (event) => { + if (event.key === "/") { + event.preventDefault() + openSearch() + } + if (event.key === "Escape") { + event.preventDefault() + closeSearch() + } + }) + + const searchButton = document.getElementById("search-icon") + searchButton.addEventListener('click', (evt) => { + openSearch() + }) + searchButton.addEventListener('keydown', (evt) => { + openSearch() + }) + searchContainer.addEventListener('click', (evt) => { + closeSearch() + }) + document.getElementById("search-space").addEventListener('click', (evt) => { + evt.stopPropagation() + }) +})() + diff --git a/assets/base.scss b/assets/styles/base.scss similarity index 98% rename from assets/base.scss rename to assets/styles/base.scss index a1ba1001..630ab3db 100644 --- a/assets/base.scss +++ b/assets/styles/base.scss @@ -238,7 +238,9 @@ body { margin: 0; height: 100vh; width: 100vw; - overflow-x: hidden; + //overflow-x: hidden; + max-width: 100%; + box-sizing: border-box; background-color: var(--light); } @@ -268,10 +270,10 @@ hr { } .singlePage { - margin: 4em 30vw; + padding: 4em 30vw; @media all and (max-width: 1200px) { - margin: 25px 5vw; + padding: 25px 5vw; } } diff --git a/assets/custom.scss b/assets/styles/custom.scss similarity index 100% rename from assets/custom.scss rename to assets/styles/custom.scss diff --git a/assets/darkmode.scss b/assets/styles/darkmode.scss similarity index 100% rename from assets/darkmode.scss rename to assets/styles/darkmode.scss diff --git a/assets/syntax.scss b/assets/styles/syntax.scss similarity index 100% rename from assets/syntax.scss rename to assets/styles/syntax.scss diff --git a/content/notes/preview changes.md b/content/notes/preview changes.md index 8d2a3892..7f12f305 100644 --- a/content/notes/preview changes.md +++ b/content/notes/preview changes.md @@ -17,7 +17,7 @@ $ go install github.com/jackyzha0/hugo-obsidian@latest $ cd # Scrape all links in your Quartz folder and generate info for Quartz -$ hugo-obsidian -input=content -output=static -index -root=. +$ hugo-obsidian -input=content -output=assets/indices -index -root=. ``` If you are running into an error saying that `command not found: hugo-obsidian`, make sure you set your `GOPATH` correctly! This will allow your terminal to correctly recognize hugo-obsidian as an executable. diff --git a/layouts/_default/section.html b/layouts/_default/section.html index abdf0b05..1a4aae05 100644 --- a/layouts/_default/section.html +++ b/layouts/_default/section.html @@ -19,6 +19,7 @@ {{partial "contact.html" .}} +{{partial "popover.html" .}} diff --git a/layouts/_default/single.html b/layouts/_default/single.html index ac8c216d..06892bdd 100644 --- a/layouts/_default/single.html +++ b/layouts/_default/single.html @@ -32,6 +32,7 @@ {{partial "footer.html" .}} + {{partial "popover.html" .}} diff --git a/layouts/_default/taxonomy.html b/layouts/_default/taxonomy.html index e0a1e876..b7a45b10 100644 --- a/layouts/_default/taxonomy.html +++ b/layouts/_default/taxonomy.html @@ -28,6 +28,7 @@ {{partial "contact.html" .}} +{{partial "popover.html" .}} diff --git a/layouts/_default/term.html b/layouts/_default/term.html index 58f024bc..16ea85cf 100644 --- a/layouts/_default/term.html +++ b/layouts/_default/term.html @@ -19,6 +19,7 @@ {{partial "contact.html" .}} +{{partial "popover.html" .}} diff --git a/layouts/index.html b/layouts/index.html index 17b69173..f0cd68e7 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -22,6 +22,7 @@ {{- .Content -}} {{partial "footer.html" .}} + {{partial "popover.html" .}} diff --git a/layouts/partials/backlinks.html b/layouts/partials/backlinks.html index d2143869..166e1fde 100644 --- a/layouts/partials/backlinks.html +++ b/layouts/partials/backlinks.html @@ -3,9 +3,9 @@ {{$url := urls.Parse .Site.BaseURL }} {{$host := strings.TrimRight "/" $url.Path }} {{$curPage := strings.TrimPrefix $host (strings.TrimRight "/" .Page.RelPermalink) }} - {{$linkIndex := getJSON "/static/linkIndex.json"}} + {{$linkIndex := getJSON "/assets/indices/linkIndex.json"}} {{$inbound := index $linkIndex.index.backlinks $curPage}} - {{$contentTable := getJSON "/static/contentIndex.json"}} + {{$contentTable := getJSON "/assets/indices/contentIndex.json"}} {{if $inbound}} {{$cleanedInbound := apply (apply $inbound "index" "." "source") "replace" "." " " "-"}} {{- range $cleanedInbound | uniq -}} diff --git a/layouts/partials/graph.html b/layouts/partials/graph.html index 14e1fdbe..ca379689 100644 --- a/layouts/partials/graph.html +++ b/layouts/partials/graph.html @@ -1,4 +1,4 @@ - +

Interactive Graph

+{{ $js := resources.Get "js/graph.js" | resources.Fingerprint "md5" }} + diff --git a/layouts/partials/head.html b/layouts/partials/head.html index 1afa3c64..80893010 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -8,52 +8,38 @@ - {{$css := slice "base.scss" "darkmode.scss" "syntax.scss" "custom.scss"}} - {{range $css}} - {{$sass := resources.Get . | resources.ToCSS }} - {{with $sass | minify}} - - {{end}} + {{$sass := resources.Match "styles/[!_]*.scss" }} + {{$css := slice }} + {{range $sass}} + {{$scss := . | resources.ToCSS (dict "outputStyle" "compressed") }} + {{$css = $css | append $scss}} {{end}} + {{$finalCss := $css | resources.Concat "styles.css" | resources.Fingerprint "md5" | resources.Minify }} + - {{- with resources.Get "darkmode.js" | minify -}} - - {{- end -}} + {{ $darkMode := resources.Get "js/darkmode.js" | resources.Fingerprint "md5" | resources.Minify }} + + {{$linkIndex := resources.Get "indices/linkIndex.json" | resources.Fingerprint "md5" | resources.Minify | }} + {{$contentIndex := resources.Get "indices/contentIndex.json" | resources.Fingerprint "md5" | resources.Minify }} {{ template "_internal/google_analytics.html" . }} -{{ partial "popover.html" .}} diff --git a/layouts/partials/popover.html b/layouts/partials/popover.html index 32f019ff..1d166228 100644 --- a/layouts/partials/popover.html +++ b/layouts/partials/popover.html @@ -1,35 +1,7 @@ {{if $.Site.Data.config.enableLinkPreview}} +{{ $js := resources.Get "js/popover.js" | resources.Fingerprint "md5" | resources.Minify }} + {{end}} \ No newline at end of file diff --git a/layouts/partials/search.html b/layouts/partials/search.html index 6cc7e24f..f727184a 100644 --- a/layouts/partials/search.html +++ b/layouts/partials/search.html @@ -5,254 +5,6 @@ - - - + +{{ $js := resources.Get "js/search.js" | resources.Fingerprint "md5" | resources.Minify }} +