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 0000000..f4fd4bb --- /dev/null +++ b/assets/js/graph.js @@ -0,0 +1,221 @@ +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) + }); + } + \ No newline at end of file diff --git a/assets/js/popover.js b/assets/js/popover.js new file mode 100644 index 0000000..ef7bb61 --- /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(base) { + const baseUrl = base.replace(window.location.origin, "") // is this useless? + document.addEventListener("DOMContentLoaded", () => { + fetchData().then(({content}) => { + const links = [...document.getElementsByClassName("internal-link")] + links.forEach(li => { + const linkDest = content[li.dataset.src.replace(baseUrl, "")] + // const linkDest = content[li.dataset.src] + 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") + }) + } + }) + }) + }) +} diff --git a/assets/js/search.js b/assets/js/search.js new file mode 100644 index 0000000..9733c04 --- /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 `<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) + const resultText = highlight(text, term) + return `<button class="result-card" id="${url}"> + <h3>${resultTitle}</h3> + <p>${resultText}</p> + </button>` + } + + 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 = `<button class="result-card"> + <h3>No results.</h3> + <p>Try another search term?</p> + </button>` + } 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 100% rename from assets/base.scss rename to assets/styles/base.scss 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/layouts/partials/graph.html b/layouts/partials/graph.html index 14e1fdb..31d008c 100644 --- a/layouts/partials/graph.html +++ b/layouts/partials/graph.html @@ -10,232 +10,16 @@ --g-link-active: #5a7282; } </style> +{{ $js := resources.Get "js/graph.js" | resources.Fingerprint "md5" }} +<script src="{{ $js.Permalink }}"></script> <script> -async function run() { - const { index, links, content } = await fetchData() - const curPage = {{ strings.TrimRight "/" .Page.Permalink }}.replace({{strings.TrimRight "/" .Site.BaseURL }}, "") - const pathColors = {{$.Site.Data.graphConfig.paths}} - let depth = {{$.Site.Data.graphConfig.depth}} - - 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 enableDrag = {{$.Site.Data.graphConfig.enableDrag}} - 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]); - - // legend - const enableLegend = {{$.Site.Data.graphConfig.enableLegend}} - 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 = {{.Site.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 - const enableZoom = {{$.Site.Data.graphConfig.enableZoom}} - 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) - }); -} - -run() + drawGraph( + {{strings.TrimRight "/" .Page.Permalink}}, + {{strings.TrimRight "/" .Site.BaseURL}}, + {{$.Site.Data.graphConfig.paths}}, + {{$.Site.Data.graphConfig.depth}}, + {{$.Site.Data.graphConfig.enableDrag}}, + {{$.Site.Data.graphConfig.enableLegend}}, + {{$.Site.Data.graphConfig.enableZoom}} + ) </script> diff --git a/layouts/partials/head.html b/layouts/partials/head.html index 1afa3c6..107f240 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -8,24 +8,21 @@ <!-- CSS Stylesheets and Fonts --> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Source+Sans+Pro:wght@400;600;700&family=Fira+Code:wght@400;700&display=swap" rel="stylesheet"> - {{$css := slice "base.scss" "darkmode.scss" "syntax.scss" "custom.scss"}} - {{range $css}} - {{$sass := resources.Get . | resources.ToCSS }} - {{with $sass | minify}} - <style> - {{.Content | safeCSS}} - </style> - {{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 }} + <link href="{{$finalCss.Permalink}}" rel="stylesheet"> - {{- with resources.Get "darkmode.js" | minify -}} - <script> - {{.Content | safeJS }} - </script> - {{- end -}} + {{ $darkMode := resources.Get "js/darkmode.js" | resources.Fingerprint "md5" | resources.Minify }} + <script src="{{$darkMode.Permalink}}"></script> <!-- Preload page vars --> <script> + const BASE_URL = {{.Site.BaseURL}} let saved = false const fetchData = async () => { if (saved) { diff --git a/layouts/partials/popover.html b/layouts/partials/popover.html index 32f019f..1d16622 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 }} +<script src="{{ $js.Permalink }}"></script> <script> - function htmlToElement(html) { - const template = document.createElement('template') - html = html.trim() - template.innerHTML = html - return template.content.firstChild - } - const baseUrl = {{strings.TrimRight "/" .Site.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(baseUrl, "")] - // const linkDest = content[li.dataset.src] - 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") - }) - } - }) - }) - }) + initPopover({{strings.TrimRight "/" .Site.BaseURL }}) </script> {{end}} \ No newline at end of file diff --git a/layouts/partials/search.html b/layouts/partials/search.html index 6cc7e24..7bc7ed0 100644 --- a/layouts/partials/search.html +++ b/layouts/partials/search.html @@ -5,254 +5,6 @@ </div> </div> </div> -<script src="https://cdn.jsdelivr.net/gh/nextapps-de/flexsearch@0.7.2/dist/flexsearch.bundle.js"></script> -<script> - // 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; - }; -</script> -<script> -async function run() { - 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 `<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) - const resultText = highlight(text, term) - return `<button class="result-card" id="${url}"> - <h3>${resultTitle}</h3> - <p>${resultText}</p> - </button>` - } - - const redir = (id, term) => { - window.location.href = "{{.Site.BaseURL}}" + `${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 = `<button class="result-card"> - <h3>No results.</h3> - <p>Try another search term?</p> - </button>` - } 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() - }) -} - -run() -</script> +<script defer src="https://cdn.jsdelivr.net/gh/nextapps-de/flexsearch@0.7.2/dist/flexsearch.bundle.js"></script> +{{ $js := resources.Get "js/search.js" | resources.Fingerprint "md5" | resources.Minify }} +<script defer src="{{ $js.Permalink }}"></script>