From ba4d3f6bc04e5470c2e68f1c740ec58460cb6ab3 Mon Sep 17 00:00:00 2001 From: Matt Gowie Date: Sun, 12 Apr 2026 13:45:21 -0600 Subject: [PATCH] feat: add "Copy for LLM" sticky header button on blog posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sticky header bar that slides in from the top when users scroll past the main header on blog posts. The bar displays the post title and a "Copy for LLM" button that copies the full blog content as clean markdown to the clipboard, optimized for pasting into AI assistants. - New partial: blog-sticky-header.html with embedded raw markdown via Hugo's .RawContent in a hidden script tag - New JS: copy-for-llm.js with scroll detection, clipboard copy, shortcode cleaning (lightboximg → markdown images, signup → removed), and "Copied!" feedback animation - SCSS: sticky bar styled with $pine background, CSS transform slide transition, responsive (title hidden on mobile) - Blog-only: does not appear on homepage, services, or case study pages Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/css/custom.scss | 72 ++++++++++++ assets/js/copy-for-llm.js | 141 +++++++++++++++++++++++ layouts/_default/single.html | 4 + layouts/partials/blog-sticky-header.html | 18 +++ layouts/partials/scripts.html | 3 + 5 files changed, 238 insertions(+) create mode 100644 assets/js/copy-for-llm.js create mode 100644 layouts/partials/blog-sticky-header.html diff --git a/assets/css/custom.scss b/assets/css/custom.scss index 8696fb3..6058632 100644 --- a/assets/css/custom.scss +++ b/assets/css/custom.scss @@ -3524,6 +3524,78 @@ footer { } } +// Blog sticky header with "Copy for LLM" button +#blogStickyHeader { + position: fixed; + top: 0; + left: 0; + width: 100%; + z-index: 100; + background: $pine; + padding: 0.55rem 1.45rem; + display: flex; + align-items: center; + justify-content: space-between; + transform: translateY(-100%); + transition: transform 0.3s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + + &.visible { + transform: translateY(0); + } + + .sticky-title { + color: #fff; + font-family: $header-font; + font-weight: 700; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - 200px); + line-height: 1.3; + } + + #copyForLlm { + white-space: nowrap; + font-size: 0.75rem; + flex-shrink: 0; + transition: background 0.2s ease; + + .fa { + margin-right: 0.25em; + } + + &.copied { + background: #2ecc71; + } + } + + @media (max-width: 767px) { + padding: 0.5rem 1rem; + + .sticky-title { + font-size: 0.8rem; + max-width: calc(100% - 160px); + } + + #copyForLlm { + font-size: 0.7rem; + } + } + + @media (max-width: 575px) { + .sticky-title { + display: none; + } + + #copyForLlm { + width: 100%; + text-align: center; + } + } +} + // Header anchor links for blog posts (H1-H6) // Entire heading is clickable with # symbol on the right and underline animation .heading-with-anchor { diff --git a/assets/js/copy-for-llm.js b/assets/js/copy-for-llm.js new file mode 100644 index 0000000..fff4967 --- /dev/null +++ b/assets/js/copy-for-llm.js @@ -0,0 +1,141 @@ +/** + * Copy for LLM - Sticky Header with Copy-to-Clipboard + * Shows a sticky bar when scrolling past the main header on blog posts. + * Copies the blog content as clean markdown to the clipboard. + */ +(function () { + "use strict"; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + + function init() { + var stickyHeader = document.getElementById("blogStickyHeader"); + var copyBtn = document.getElementById("copyForLlm"); + var rawContent = document.getElementById("rawMarkdownContent"); + + if (!stickyHeader || !copyBtn || !rawContent) return; + + // --- Scroll-based visibility --- + var pageHeader = document.querySelector("header[aria-label='header']"); + if (!pageHeader) return; + + function updateVisibility() { + var headerBottom = pageHeader.getBoundingClientRect().bottom; + if (headerBottom <= 0) { + stickyHeader.classList.add("visible"); + } else { + stickyHeader.classList.remove("visible"); + } + } + + updateVisibility(); + + var scrollTimeout; + window.addEventListener( + "scroll", + function () { + if (!scrollTimeout) { + scrollTimeout = setTimeout(function () { + updateVisibility(); + scrollTimeout = null; + }, 50); + } + }, + { passive: true }, + ); + + // --- Copy logic --- + copyBtn.addEventListener("click", function () { + var content = rawContent.textContent; + content = cleanContent(content); + + copyToClipboard(content).then(function () { + showCopiedFeedback(); + }); + }); + + function cleanContent(text) { + // Fix relative or protocol-relative URL line — make it absolute + text = text.replace( + /^(URL: )(\/\/[^\s]*|\/[^\s]*)/m, + function (match, prefix, path) { + if (path.startsWith("//")) { + return prefix + window.location.protocol + path; + } + return prefix + window.location.origin + path; + }, + ); + + // Convert lightboximg shortcodes to markdown images + // {{< lightboximg "/path/to/img.png" "Alt text" >}} + text = text.replace( + /\{\{<\s*lightboximg\s+"([^"]+)"\s+"([^"]+)"\s*>\}\}/g, + "![$2]($1)", + ); + + // Remove signup shortcodes + text = text.replace(/\{\{<\s*signup\s*>\}\}/g, ""); + + // Remove other common shortcodes that don't translate to markdown + text = text.replace( + /\{\{<\s*(button|buttonout)\s+link="([^"]+)"\s+text="([^"]+)"\s*>\}\}/g, + "[$3]($2)", + ); + + // Convert HTML line breaks to newlines + text = text.replace(//gi, "\n"); + + // Clean up excessive blank lines (3+ newlines → 2) + text = text.replace(/\n{3,}/g, "\n\n"); + + // Trim leading/trailing whitespace + text = text.trim(); + + return text; + } + + function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).catch(function () { + return fallbackCopy(text); + }); + } + return fallbackCopy(text); + } + + function fallbackCopy(text) { + return new Promise(function (resolve, reject) { + var textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand("copy"); + resolve(); + } catch (err) { + reject(err); + } finally { + document.body.removeChild(textarea); + } + }); + } + + function showCopiedFeedback() { + var originalHTML = copyBtn.innerHTML; + copyBtn.innerHTML = ' Copied!'; + copyBtn.classList.add("copied"); + + setTimeout(function () { + copyBtn.innerHTML = originalHTML; + copyBtn.classList.remove("copied"); + }, 2000); + } + } +})(); diff --git a/layouts/_default/single.html b/layouts/_default/single.html index f95c347..cc86a03 100644 --- a/layouts/_default/single.html +++ b/layouts/_default/single.html @@ -2,6 +2,10 @@ {{ partial "header.html" . }} + {{ if eq .Section "blog" }} + {{ partial "blog-sticky-header.html" . }} + {{ end }} +
{{ if eq .Section "blog" }} diff --git a/layouts/partials/blog-sticky-header.html b/layouts/partials/blog-sticky-header.html new file mode 100644 index 0000000..837245c --- /dev/null +++ b/layouts/partials/blog-sticky-header.html @@ -0,0 +1,18 @@ +{{ if .RawContent }} +
+ {{ .Title }} + +
+ +{{ end }} diff --git a/layouts/partials/scripts.html b/layouts/partials/scripts.html index 8e59459..c96718a 100644 --- a/layouts/partials/scripts.html +++ b/layouts/partials/scripts.html @@ -29,6 +29,9 @@ {{ $tocJS := resources.Get "js/floating-toc.js" }} {{ $secureTocJS := $tocJS | resources.Minify }} +{{ $copyJS := resources.Get "js/copy-for-llm.js" }} +{{ $secureCopyJS := $copyJS | resources.Minify }} + {{ end }} {{ partial "lightbox.html" .}}