diff --git a/.github/workflows/release-docs.yml b/.github/workflows/release-docs.yml index 12b4147..618249c 100644 --- a/.github/workflows/release-docs.yml +++ b/.github/workflows/release-docs.yml @@ -24,6 +24,18 @@ jobs: sudo apt-get install -y pandoc \ jq \ curl + - name: Build pandoc after-body include (inline quote.js) + shell: bash + run: | + mkdir -p build + { + echo '' + echo '' + } > build/after-body.html - name: Build artifacts run: | @@ -41,6 +53,7 @@ jobs: --metadata-file src/metadata.yml \ --embed-resources \ --metadata title="CMBA ${doc}" \ + --include-after-body=build/after-body.html \ --css src/style.css \ -o "dist/${doc}.html" diff --git a/src/quote.js b/src/quote.js new file mode 100644 index 0000000..665908c --- /dev/null +++ b/src/quote.js @@ -0,0 +1,182 @@ +(() => { + // Inject Quote buttons into Pandoc-style headings and copy a section-range quote to clipboard. + // + // Assumptions (matches your sample): + // - Headings are h1/h2 with stable ids (anchors). + // - A "section" is: the heading + all following sibling elements until the next heading + // of the same or higher level (H2 stops at next H2 or H1; H1 stops at next H1). + // + // Primary target: GroupMe. We optimize text/plain to be readable and "quote-like". + // We still provide text/html for rich paste targets (Docs/Email). + // + // No dependencies. + + const HEADING_SELECTOR = "h1[id], h2[id]"; // change to "h2[id]" if you only want section-level + + document.addEventListener("DOMContentLoaded", () => { + injectQuoteButtons(); + }); + + function injectQuoteButtons() { + document.querySelectorAll(HEADING_SELECTOR).forEach((heading) => { + // Avoid double-injection + if (heading.querySelector(":scope > .quote-btn")) return; + + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "quote-btn"; + btn.textContent = "Quote"; + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + onQuoteClick(btn, heading); + }); + + // Add a little separation from heading text + heading.appendChild(document.createTextNode(" ")); + heading.appendChild(btn); + }); + } + + async function onQuoteClick(btn, heading) { + const originalText = btn.textContent; + + try { + btn.textContent = "Copying…"; + btn.disabled = true; + + await copyQuotedSection(heading); + + btn.textContent = "Copied"; + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + }, 800); + } catch (err) { + console.error(err); + btn.textContent = "Failed"; + setTimeout(() => { + btn.textContent = originalText; + btn.disabled = false; + }, 1200); + } + } + + async function copyQuotedSection(heading) { + if (!heading.id) throw new Error("Heading missing id."); + + const level = heading.tagName; // H1 or H2 + + // Clone heading + subsequent siblings for the quote range + const nodes = []; + nodes.push(heading.cloneNode(true)); + + let el = heading.nextElementSibling; + while (el) { + if (isBoundary(level, el)) break; + nodes.push(el.cloneNode(true)); + el = el.nextElementSibling; + } + + // Remove injected button from cloned heading (so it doesn't show up in pasted content) + nodes[0].querySelector(".quote-btn")?.remove(); + + const container = document.createElement("div"); + nodes.forEach((n) => container.appendChild(n)); + + const href = buildAnchorUrl(heading.id); + + // HTML (for rich paste targets) + const html = buildHtmlQuote(container.innerHTML, href); + + // Plain text (optimized for GroupMe) + const plain = buildGroupMePlainQuote(container, href); + + await writeClipboardMultiFormat({ html, plain, url: href }); + } + + function isBoundary(level, el) { + // Stop at next heading of same or higher rank. + // H2 stops at next H1 or H2. + // H1 stops at next H1. + if (level === "H1") return el.tagName === "H1"; + if (level === "H2") return el.tagName === "H1" || el.tagName === "H2"; + return false; + } + + function buildAnchorUrl(id) { + const url = new URL(window.location.href); + url.hash = id; + return url.toString(); + } + + function buildHtmlQuote(innerHtml, href) { + const safeHref = escapeHtmlAttr(href); + const safeText = escapeHtml(href); + + return ` +
+ ${innerHtml} +

${safeText}

+
+ `.trim(); + } + + function buildGroupMePlainQuote(container, href) { + // Create a readable quote-like block: + // - Prefix lines with "> " to visually indicate quoting (works even if not rendered specially) + // - Include Source link + const text = normalizePlainText(container); + + const quoted = text + .split("\n") + .map((line) => (line.trim().length ? `> ${line}` : `>`)) + .join("\n"); + + return `${quoted}\n> \n> Source: ${href}`; + } + + function normalizePlainText(container) { + // Use innerText to approximate what the user sees (captures list numbering reasonably in many browsers). + // Then normalize spacing. + return (container.innerText || "") + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + async function writeClipboardMultiFormat({ html, plain, url }) { + // Best path: multi-format clipboard write (Chromium/Edge, etc.) + if (navigator.clipboard?.write) { + const item = new ClipboardItem({ + "text/html": new Blob([html], { type: "text/html" }), + "text/plain": new Blob([plain], { type: "text/plain" }), + // Best-effort URL type; some paste targets prefer this + "text/uri-list": new Blob([url], { type: "text/uri-list" }), + }); + await navigator.clipboard.write([item]); + return; + } + + // Fallback: plain text only + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(plain); + return; + } + + throw new Error("Clipboard API unavailable."); + } + + function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function escapeHtmlAttr(s) { + return escapeHtml(s); + } +})(); \ No newline at end of file