From 6846d314d654426e058269464978b365adc239b5 Mon Sep 17 00:00:00 2001 From: Claudio Yanes Date: Fri, 4 Mar 2022 02:07:51 +0000 Subject: [PATCH] Move css and js to appropriate files Having the CSS and JS in the html template produces pages larger than necessary, as each page need to contain all the js/css. Separating them in appropriate files allow the browser to just download them once and use them for all the pages. This is even more effective with an aggressive cache policy for the js and css, something that can be done without fear thanks to the implemented cache-busting. Also, having then in separate files allows us to use Hugo pipelines for minimizing the code. --- assets/{ => js}/darkmode.js | 0 assets/js/graph.js | 221 ++++++++++++++++++++++++++ assets/js/popover.js | 34 ++++ assets/js/search.js | 247 +++++++++++++++++++++++++++++ assets/{ => styles}/base.scss | 0 assets/{ => styles}/custom.scss | 0 assets/{ => styles}/darkmode.scss | 0 assets/{ => styles}/syntax.scss | 0 layouts/partials/graph.html | 238 ++-------------------------- layouts/partials/head.html | 23 ++- layouts/partials/popover.html | 34 +--- layouts/partials/search.html | 254 +----------------------------- 12 files changed, 529 insertions(+), 522 deletions(-) rename assets/{ => js}/darkmode.js (100%) create mode 100644 assets/js/graph.js create mode 100644 assets/js/popover.js create mode 100644 assets/js/search.js rename assets/{ => styles}/base.scss (100%) rename assets/{ => styles}/custom.scss (100%) rename assets/{ => styles}/darkmode.scss (100%) rename assets/{ => styles}/syntax.scss (100%) 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..f4fd4bb6 --- /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 00000000..ef7bb615 --- /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 = `
+

${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..9733c048 --- /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 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 14e1fdbe..31d008cd 100644 --- a/layouts/partials/graph.html +++ b/layouts/partials/graph.html @@ -10,232 +10,16 @@ --g-link-active: #5a7282; } +{{ $js := resources.Get "js/graph.js" | resources.Fingerprint "md5" }} + diff --git a/layouts/partials/head.html b/layouts/partials/head.html index 1afa3c64..107f2403 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -8,24 +8,21 @@ - {{$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 }} + {{end}} \ No newline at end of file diff --git a/layouts/partials/search.html b/layouts/partials/search.html index 6cc7e24f..7bc7ed0c 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 }} +