Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions assets/css/custom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
141 changes: 141 additions & 0 deletions assets/js/copy-for-llm.js
Original file line number Diff line number Diff line change
@@ -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();
});
Comment on lines +56 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle clipboard copy failures to avoid silent/unhandled errors.

Lines 56-58 only handle success. Add a .catch(...) to prevent unhandled promise rejection and give the user failure feedback.

🛠️ Suggested fix
-      copyToClipboard(content).then(function () {
-        showCopiedFeedback();
-      });
+      copyToClipboard(content)
+        .then(function () {
+          showCopiedFeedback();
+        })
+        .catch(function () {
+          var originalHTML = copyBtn.innerHTML;
+          copyBtn.innerHTML = '<i class="fa fa-times"></i> Copy failed';
+          setTimeout(function () {
+            copyBtn.innerHTML = originalHTML;
+          }, 2000);
+        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
copyToClipboard(content).then(function () {
showCopiedFeedback();
});
copyToClipboard(content)
.then(function () {
showCopiedFeedback();
})
.catch(function () {
var originalHTML = copyBtn.innerHTML;
copyBtn.innerHTML = '<i class="fa fa-times"></i> Copy failed';
setTimeout(function () {
copyBtn.innerHTML = originalHTML;
}, 2000);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@assets/js/copy-for-llm.js` around lines 56 - 58, The
copyToClipboard(content).then(...) chain currently only handles success and can
produce unhandled promise rejections; add a .catch(...) on the promise returned
by copyToClipboard to handle failures, log the error (e.g., console.error or
existing logger) and call a user-facing failure handler (create or reuse a
function like showCopyFailedFeedback or showCopyErrorFeedback) so users get
visible feedback instead of silent failures; update the call site where
copyToClipboard and showCopiedFeedback are used to include this .catch and
ensure the error handler is implemented.

});

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(/<br\s*\/?>/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 = '<i class="fa fa-check"></i> Copied!';
copyBtn.classList.add("copied");

setTimeout(function () {
copyBtn.innerHTML = originalHTML;
copyBtn.classList.remove("copied");
}, 2000);
}
}
})();
4 changes: 4 additions & 0 deletions layouts/_default/single.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<body {{ if .Params.id }}id='{{ .Params.id }}' {{end}} class="single-default">
{{ partial "header.html" . }}

{{ if eq .Section "blog" }}
{{ partial "blog-sticky-header.html" . }}
{{ end }}

<main>
<div class="container">
{{ if eq .Section "blog" }}
Expand Down
18 changes: 18 additions & 0 deletions layouts/partials/blog-sticky-header.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{{ if .RawContent }}
<div id="blogStickyHeader">
<span class="sticky-title">{{ .Title }}</span>
<button id="copyForLlm" class="button sml" aria-label="Copy page content as markdown for LLM">
<i class="fa fa-copy"></i> Copy for LLM
</button>
</div>
<script id="rawMarkdownContent" type="text/plain">
# {{ .Title | safeHTML }}

URL: {{ .Permalink }}
{{ with .Params.author }}Author: {{ . }}
{{ end }}Published: {{ .Date.Format "January 2, 2006" }}
{{ with .Params.description }}
> {{ . | safeHTML }}
{{ end }}
{{ .RawContent | safeHTML }}</script>
Comment on lines +9 to +17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove safeHTML inside the embedded script payload to avoid script-breakout injection.

At Line 17 (and similarly Lines 9 and 15), rendering raw content with safeHTML inside a <script> block can prematurely close the script if content contains </script>, breaking the page and creating an injection vector.

🔧 Suggested fix
 <script id="rawMarkdownContent" type="text/plain">
-# {{ .Title | safeHTML }}
+# {{ .Title }}
 
 URL: {{ .Permalink }}
 {{ with .Params.author }}Author: {{ . }}
 {{ end }}Published: {{ .Date.Format "January 2, 2006" }}
 {{ with .Params.description }}
-> {{ . | safeHTML }}
+> {{ . }}
 {{ end }}
-{{ .RawContent | safeHTML }}</script>
+{{ .RawContent }}</script>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# {{ .Title | safeHTML }}
URL: {{ .Permalink }}
{{ with .Params.author }}Author: {{ . }}
{{ end }}Published: {{ .Date.Format "January 2, 2006" }}
{{ with .Params.description }}
> {{ . | safeHTML }}
{{ end }}
{{ .RawContent | safeHTML }}</script>
# {{ .Title }}
URL: {{ .Permalink }}
{{ with .Params.author }}Author: {{ . }}
{{ end }}Published: {{ .Date.Format "January 2, 2006" }}
{{ with .Params.description }}
> {{ . }}
{{ end }}
{{ .RawContent }}</script>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@layouts/partials/blog-sticky-header.html` around lines 9 - 17, The template
is using the safeHTML transformer inside a <script> payload which can allow
content containing </script> to break out and cause script-breakout injection;
remove the use of safeHTML for the fields rendered inside the script block (e.g.
.RawContent, .Title, .Permalink, .Params.description, .Params.author) and
instead render them with the default auto-escaping or a JSON-encoding helper
(e.g. jsonify) or an HTML-escaping helper appropriate for Hugo templates so the
content is safely escaped and cannot prematurely close the <script> tag.

{{ end }}
3 changes: 3 additions & 0 deletions layouts/partials/scripts.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
{{ $tocJS := resources.Get "js/floating-toc.js" }}
{{ $secureTocJS := $tocJS | resources.Minify }}
<script src="{{ $secureTocJS.Permalink }}"></script>
{{ $copyJS := resources.Get "js/copy-for-llm.js" }}
{{ $secureCopyJS := $copyJS | resources.Minify }}
<script src="{{ $secureCopyJS.Permalink }}"></script>
{{ end }}

{{ partial "lightbox.html" .}}
Loading