Claudio Yanes
2022-03-04 1313bd9779c638f09b8901f8432d6bc39910bce3
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.
3 files added
4 files modified
5 files renamed
1051 ■■■■ changed files
assets/js/darkmode.js patch | view | raw | blame | history
assets/js/graph.js 221 ●●●●● patch | view | raw | blame | history
assets/js/popover.js 34 ●●●●● patch | view | raw | blame | history
assets/js/search.js 247 ●●●●● patch | view | raw | blame | history
assets/styles/base.scss patch | view | raw | blame | history
assets/styles/custom.scss patch | view | raw | blame | history
assets/styles/darkmode.scss patch | view | raw | blame | history
assets/styles/syntax.scss patch | view | raw | blame | history
layouts/partials/graph.html 238 ●●●●● patch | view | raw | blame | history
layouts/partials/head.html 23 ●●●●● patch | view | raw | blame | history
layouts/partials/popover.html 34 ●●●●● patch | view | raw | blame | history
layouts/partials/search.html 254 ●●●●● patch | view | raw | blame | history
assets/js/darkmode.js
assets/js/graph.js
New file
@@ -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)
    });
  }
assets/js/popover.js
New file
@@ -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")
                })
            }
            })
        })
    })
}
assets/js/search.js
New file
@@ -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()
    })
})()
assets/styles/base.scss
assets/styles/custom.scss
assets/styles/darkmode.scss
assets/styles/syntax.scss
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>
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>
    {{$sass := resources.Match "styles/[!_]*.scss" }}
    {{$css := slice }}
    {{range $sass}}
    {{$scss := . | resources.ToCSS (dict "outputStyle" "compressed") }}
    {{$css = $css | append $scss}}
    {{end}}
    {{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) {
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}}
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>