mykiwi/mykiwi.blogpublic Fork
⑂ 94576dd
Commits
⬇ Clone ▾
git clone https://git.rickub.com/mykiwi/mykiwi.blog.git
git clone ssh://git@rickub.com/mykiwi/mykiwi.blog.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Offer to share a selected passage on Bluesky or as a link

Selecting text in a post shows a small bar, as Leaflet does: Bluesky opens
the compose intent prefilled with the quote and a link, Link copies a URL
with a text fragment that scrolls to and highlights the passage.
Romain Gautier committed 2026-09-28T23:33:08+02:00 Browse files
94576dd parent: 800d236
modified blog/assets/css/style.scss +72 -0
@@ -253,6 +253,13 @@ body {
253253 color: var(--paper);
254254 }
255255
256+// The passage a quote link (#:~:text=, see js/quote-share.js) points at, in
257+// the site's own wash rather than the browser's stock purple.
258+::target-text {
259+ background: var(--accent-wash);
260+ color: inherit;
261+}
262+
256263 // --- Layout ------------------------------------------------------------
257264
258265 .wrap {
@@ -671,6 +678,9 @@ body {
671678 opacity: 0.4;
672679 text-decoration: none;
673680 transition: opacity 0.15s ease;
681+ // Chrome, not text: kept out of a selection so a copied or quoted
682+ // passage (js/quote-share.js) does not come out with § after every heading.
683+ user-select: none;
674684
675685 &:hover,
676686 &:focus-visible {
@@ -1735,6 +1745,68 @@ blockquote.alert {
17351745 }
17361746 }
17371747
1748+// The bar js/quote-share.js puts next to a passage selected in a post. Fixed,
1749+// not absolute: the script places it from the selection's client rects, which
1750+// are viewport coordinates, and moves it along on scroll.
1751+.quote-share {
1752+ @extend %chrome;
1753+ position: fixed;
1754+ z-index: 10;
1755+ display: flex;
1756+ align-items: center;
1757+ gap: 0.35rem;
1758+ padding: 0.3rem 0.5rem;
1759+ white-space: nowrap;
1760+ background: var(--paper-raised);
1761+ border: 1px solid var(--rule-strong);
1762+ border-radius: 4px;
1763+ box-shadow: 0 6px 18px #0000001f;
1764+
1765+ &[hidden] {
1766+ display: none;
1767+ }
1768+
1769+ button {
1770+ display: inline-flex;
1771+ align-items: center;
1772+ gap: 0.35rem;
1773+ padding: 0.2rem 0.3rem;
1774+ font: inherit;
1775+ letter-spacing: inherit;
1776+ text-transform: inherit;
1777+ color: var(--ink);
1778+ background: none;
1779+ border: 0;
1780+ border-radius: 3px;
1781+ cursor: pointer;
1782+
1783+ &:hover,
1784+ &:focus-visible {
1785+ color: var(--accent);
1786+ background: var(--accent-wash);
1787+ }
1788+ }
1789+
1790+ svg {
1791+ width: 0.85rem;
1792+ height: 0.85rem;
1793+ flex: none;
1794+ }
1795+}
1796+
1797+.quote-share__sep {
1798+ align-self: stretch;
1799+ width: 1px;
1800+ margin: 0.15rem 0;
1801+ background: var(--rule-strong);
1802+}
1803+
1804+@media print {
1805+ .quote-share {
1806+ display: none !important;
1807+ }
1808+}
1809+
17381810 .feed-grid {
17391811 display: grid;
17401812 grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
@@ -253,6 +253,13 @@ body {
253 color: var(--paper);253 color: var(--paper);
254 }254 }
255 255
256+// The passage a quote link (#:~:text=, see js/quote-share.js) points at, in
257+// the site's own wash rather than the browser's stock purple.
258+::target-text {
259+ background: var(--accent-wash);
260+ color: inherit;
261+}
262+
256 // --- Layout ------------------------------------------------------------263 // --- Layout ------------------------------------------------------------
257 264
258 .wrap {265 .wrap {
@@ -671,6 +678,9 @@ body {
671 opacity: 0.4;678 opacity: 0.4;
672 text-decoration: none;679 text-decoration: none;
673 transition: opacity 0.15s ease;680 transition: opacity 0.15s ease;
681+ // Chrome, not text: kept out of a selection so a copied or quoted
682+ // passage (js/quote-share.js) does not come out with § after every heading.
683+ user-select: none;
674 684
675 &:hover,685 &:hover,
676 &:focus-visible {686 &:focus-visible {
@@ -1735,6 +1745,68 @@ blockquote.alert {
1735 }1745 }
1736 }1746 }
1737 1747
1748+// The bar js/quote-share.js puts next to a passage selected in a post. Fixed,
1749+// not absolute: the script places it from the selection's client rects, which
1750+// are viewport coordinates, and moves it along on scroll.
1751+.quote-share {
1752+ @extend %chrome;
1753+ position: fixed;
1754+ z-index: 10;
1755+ display: flex;
1756+ align-items: center;
1757+ gap: 0.35rem;
1758+ padding: 0.3rem 0.5rem;
1759+ white-space: nowrap;
1760+ background: var(--paper-raised);
1761+ border: 1px solid var(--rule-strong);
1762+ border-radius: 4px;
1763+ box-shadow: 0 6px 18px #0000001f;
1764+
1765+ &[hidden] {
1766+ display: none;
1767+ }
1768+
1769+ button {
1770+ display: inline-flex;
1771+ align-items: center;
1772+ gap: 0.35rem;
1773+ padding: 0.2rem 0.3rem;
1774+ font: inherit;
1775+ letter-spacing: inherit;
1776+ text-transform: inherit;
1777+ color: var(--ink);
1778+ background: none;
1779+ border: 0;
1780+ border-radius: 3px;
1781+ cursor: pointer;
1782+
1783+ &:hover,
1784+ &:focus-visible {
1785+ color: var(--accent);
1786+ background: var(--accent-wash);
1787+ }
1788+ }
1789+
1790+ svg {
1791+ width: 0.85rem;
1792+ height: 0.85rem;
1793+ flex: none;
1794+ }
1795+}
1796+
1797+.quote-share__sep {
1798+ align-self: stretch;
1799+ width: 1px;
1800+ margin: 0.15rem 0;
1801+ background: var(--rule-strong);
1802+}
1803+
1804+@media print {
1805+ .quote-share {
1806+ display: none !important;
1807+ }
1808+}
1809+
1738 .feed-grid {1810 .feed-grid {
1739 display: grid;1811 display: grid;
1740 grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));1812 grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
added blog/assets/js/quote-share.js +189 -0
new file mode 100644
@@ -0,0 +1,189 @@
1+/*
2+ Select a passage of a post, get a small bar offering to pass it on: to
3+ Bluesky as a prefilled post, or as a link that lands on that very passage.
4+
5+ The link is a text fragment (#:~:text=start,end), which is the browser's
6+ own way of pointing inside a page: it scrolls to the words and highlights
7+ them, with nothing on this side having to understand the URL. A browser
8+ that does not know the syntax ignores the hash and opens the top of the
9+ post, which is where a plain link would have landed anyway.
10+
11+ The bar itself is rendered by _default/single.html, hidden, with every word
12+ it says already in the page's language - so no string is spelled out here.
13+ hx-boost morphs a fresh copy of it in on every navigation, which is why it
14+ is looked up each time rather than held on to, and why every listener is
15+ on the document rather than on the bar.
16+*/
17+(() => {
18+ const BODY = ".post-body";
19+ const BAR = ".quote-share";
20+ // Bluesky's post limit, in graphemes. The composer counts a link at its
21+ // shortened, displayed length (host and the start of the path), so the
22+ // link is charged a flat allowance rather than its full length.
23+ const POST_LIMIT = 300;
24+ const LINK_ALLOWANCE = 32;
25+ // A passage up to this many words is quoted whole in the fragment; past
26+ // it, the fragment names only its first and last few words.
27+ const EXACT_WORDS = 10;
28+ const EDGE_WORDS = 4;
29+ const SHOWN = 1900;
30+ let raf = 0, hide;
31+
32+ // The live selection, if it is a non-empty one inside a post and the page
33+ // has a bar to offer - null in every other case, which hides the bar.
34+ const current = () => {
35+ const sel = document.getSelection();
36+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
37+ const range = sel.getRangeAt(0);
38+ let node = range.commonAncestorContainer;
39+ if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement;
40+ if (!node?.closest(BODY)) return null;
41+ const bar = document.querySelector(BAR);
42+ const text = sel.toString().replace(/­/g, "").trim();
43+ return bar && text ? {sel, range, text, bar} : null;
44+ };
45+
46+ // Whether the selection was dragged right to left. `direction` is recent
47+ // (Chrome only, at the time of writing); the node comparison is the same
48+ // question asked the long way round.
49+ const backward = (sel) => {
50+ if (sel.direction) return sel.direction === "backward";
51+ if (sel.anchorNode === sel.focusNode) return sel.focusOffset < sel.anchorOffset;
52+ return !!(sel.anchorNode.compareDocumentPosition(sel.focusNode) & Node.DOCUMENT_POSITION_PRECEDING);
53+ };
54+
55+ // Next to the end the reader is holding, the way Leaflet does it: under
56+ // the last line of a selection dragged forwards, over the first line of
57+ // one dragged backwards. Whichever side has no room gives way to the
58+ // other, and the bar never runs off either edge of the viewport.
59+ const place = ({sel, range, bar}) => {
60+ const rects = range.getClientRects();
61+ const all = range.getBoundingClientRect();
62+ const back = backward(sel);
63+ const edge = (back ? rects[0] : rects[rects.length - 1]) || all;
64+ const w = bar.offsetWidth, h = bar.offsetHeight, gap = 8;
65+ let top = back ? edge.top - h - gap : edge.bottom + gap;
66+ if (top + h > innerHeight - gap) top = all.top - h - gap;
67+ if (top < gap) top = Math.min(all.bottom + gap, innerHeight - h - gap);
68+ const x = back ? edge.left : edge.right;
69+ const left = Math.max(gap, Math.min(x - w / 2, innerWidth - w - gap));
70+ bar.style.top = `${Math.round(top)}px`;
71+ bar.style.left = `${Math.round(left)}px`;
72+ };
73+
74+ const update = () => {
75+ const now = current();
76+ for (const bar of document.querySelectorAll(BAR)) {
77+ if (bar !== now?.bar) bar.hidden = true;
78+ }
79+ if (!now) return;
80+ now.bar.hidden = false;
81+ place(now);
82+ };
83+
84+ // Text fragments only match whole words, so a selection that starts or
85+ // ends halfway through one would point at nothing. When it does, the
86+ // stray piece of word is left out of the fragment - the quote still says
87+ // what the reader chose.
88+ const WORD = /[\p{L}\p{N}]/u;
89+ const midWord = (node, offset) =>
90+ node.nodeType === Node.TEXT_NODE &&
91+ WORD.test(node.data.charAt(offset - 1)) && WORD.test(node.data.charAt(offset));
92+
93+ // -, & and , are the fragment syntax's own punctuation and have to be
94+ // escaped; encodeURIComponent already takes care of the other two.
95+ const enc = (s) => encodeURIComponent(s).replace(/-/g, "%2D");
96+
97+ const fragment = ({range, text}) => {
98+ // A fragment's start and end each have to sit within one block, so a
99+ // passage running over several paragraphs is named by its first and last.
100+ // split() with a capture group keeps the whitespace in between, so the
101+ // no-break spaces the build puts in reach the fragment as they are in the
102+ // page. Words sit at the even indexes. With a single line, first and last
103+ // are the same array, and both trims below apply to it.
104+ const lines = text.split(/\n+/).map((l) => l.trim()).filter(Boolean).map((l) => l.split(/(\s+)/));
105+ const first = lines[0], last = lines[lines.length - 1];
106+ if (midWord(range.startContainer, range.startOffset) && first.length > 1) first.splice(0, 2);
107+ if (midWord(range.endContainer, range.endOffset) && last.length > 1) last.splice(-2);
108+ if (lines.length === 1 && (first.length + 1) / 2 <= EXACT_WORDS) return enc(first.join(""));
109+ const start = first.slice(0, EDGE_WORDS * 2 - 1).join("");
110+ const end = last.slice(-(EDGE_WORDS * 2 - 1)).join("");
111+ return `${enc(start)},${enc(end)}`;
112+ };
113+
114+ // location, not <link rel="canonical">: hx-boost leaves the first page's
115+ // canonical behind in <head> for the rest of the visit.
116+ const link = (now) => `${location.origin}${location.pathname}#:~:text=${fragment(now)}`;
117+
118+ const graphemes = (s) =>
119+ typeof Intl.Segmenter === "function"
120+ ? [...new Intl.Segmenter().segment(s)].map((g) => g.segment)
121+ : [...s];
122+
123+ const post = (now) => {
124+ const {bar, text} = now;
125+ const open = bar.dataset.quoteOpen || "“", close = bar.dataset.quoteClose || "”";
126+ let quote = text.replace(/\s*\n\s*/g, "\n");
127+ const room = POST_LIMIT - LINK_ALLOWANCE - graphemes(open + close).length - 2;
128+ const g = graphemes(quote);
129+ if (g.length > room) quote = g.slice(0, room - 1).join("").trimEnd() + "…";
130+ return `${open}${quote}${close}\n\n${link(now)}`;
131+ };
132+
133+ // The same corner toast the feed page uses (style.scss's .copy-toast).
134+ const toast = (message) => {
135+ let el = document.querySelector(".copy-toast");
136+ if (!el) {
137+ el = document.createElement("div");
138+ el.className = "copy-toast";
139+ el.setAttribute("role", "status");
140+ document.body.appendChild(el);
141+ }
142+ el.textContent = message;
143+ el.classList.remove("is-on");
144+ void el.getBoundingClientRect().width;
145+ el.classList.add("is-on");
146+ clearTimeout(hide);
147+ hide = setTimeout(() => el.classList.remove("is-on"), SHOWN);
148+ };
149+
150+ const actions = {
151+ bluesky(now) {
152+ const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(post(now))}`;
153+ window.open(url, "_blank", "noopener,noreferrer");
154+ },
155+ async link(now) {
156+ try {
157+ await navigator.clipboard.writeText(link(now));
158+ toast(now.bar.dataset.quoteCopied || "Copied");
159+ } catch {
160+ // No clipboard (insecure context, refused permission): the page's
161+ // own address bar is the next best thing to copy from.
162+ history.replaceState(history.state, "", link(now));
163+ }
164+ },
165+ };
166+
167+ document.addEventListener("selectionchange", update);
168+ // Scrolling moves the selection and not the fixed bar, so the bar follows,
169+ // once per frame at most.
170+ const follow = () => {
171+ raf ||= requestAnimationFrame(() => { raf = 0; const now = current(); if (now && !now.bar.hidden) place(now); });
172+ };
173+ addEventListener("scroll", follow, {capture: true, passive: true});
174+ addEventListener("resize", follow);
175+
176+ // Pressing a button must not be what clears the selection it acts on.
177+ document.addEventListener("mousedown", (e) => {
178+ if (e.target.closest?.(BAR)) e.preventDefault();
179+ });
180+ document.addEventListener("click", (e) => {
181+ const button = e.target.closest?.(`${BAR} [data-quote-action]`);
182+ const now = button && current();
183+ if (now) actions[button.dataset.quoteAction]?.(now);
184+ });
185+ document.addEventListener("keydown", (e) => {
186+ if (e.key !== "Escape") return;
187+ for (const bar of document.querySelectorAll(BAR)) bar.hidden = true;
188+ });
189+})();
new file mode 100644
@@ -0,0 +1,189 @@
1+/*
2+ Select a passage of a post, get a small bar offering to pass it on: to
3+ Bluesky as a prefilled post, or as a link that lands on that very passage.
4+
5+ The link is a text fragment (#:~:text=start,end), which is the browser's
6+ own way of pointing inside a page: it scrolls to the words and highlights
7+ them, with nothing on this side having to understand the URL. A browser
8+ that does not know the syntax ignores the hash and opens the top of the
9+ post, which is where a plain link would have landed anyway.
10+
11+ The bar itself is rendered by _default/single.html, hidden, with every word
12+ it says already in the page's language - so no string is spelled out here.
13+ hx-boost morphs a fresh copy of it in on every navigation, which is why it
14+ is looked up each time rather than held on to, and why every listener is
15+ on the document rather than on the bar.
16+*/
17+(() => {
18+ const BODY = ".post-body";
19+ const BAR = ".quote-share";
20+ // Bluesky's post limit, in graphemes. The composer counts a link at its
21+ // shortened, displayed length (host and the start of the path), so the
22+ // link is charged a flat allowance rather than its full length.
23+ const POST_LIMIT = 300;
24+ const LINK_ALLOWANCE = 32;
25+ // A passage up to this many words is quoted whole in the fragment; past
26+ // it, the fragment names only its first and last few words.
27+ const EXACT_WORDS = 10;
28+ const EDGE_WORDS = 4;
29+ const SHOWN = 1900;
30+ let raf = 0, hide;
31+
32+ // The live selection, if it is a non-empty one inside a post and the page
33+ // has a bar to offer - null in every other case, which hides the bar.
34+ const current = () => {
35+ const sel = document.getSelection();
36+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
37+ const range = sel.getRangeAt(0);
38+ let node = range.commonAncestorContainer;
39+ if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement;
40+ if (!node?.closest(BODY)) return null;
41+ const bar = document.querySelector(BAR);
42+ const text = sel.toString().replace(/­/g, "").trim();
43+ return bar && text ? {sel, range, text, bar} : null;
44+ };
45+
46+ // Whether the selection was dragged right to left. `direction` is recent
47+ // (Chrome only, at the time of writing); the node comparison is the same
48+ // question asked the long way round.
49+ const backward = (sel) => {
50+ if (sel.direction) return sel.direction === "backward";
51+ if (sel.anchorNode === sel.focusNode) return sel.focusOffset < sel.anchorOffset;
52+ return !!(sel.anchorNode.compareDocumentPosition(sel.focusNode) & Node.DOCUMENT_POSITION_PRECEDING);
53+ };
54+
55+ // Next to the end the reader is holding, the way Leaflet does it: under
56+ // the last line of a selection dragged forwards, over the first line of
57+ // one dragged backwards. Whichever side has no room gives way to the
58+ // other, and the bar never runs off either edge of the viewport.
59+ const place = ({sel, range, bar}) => {
60+ const rects = range.getClientRects();
61+ const all = range.getBoundingClientRect();
62+ const back = backward(sel);
63+ const edge = (back ? rects[0] : rects[rects.length - 1]) || all;
64+ const w = bar.offsetWidth, h = bar.offsetHeight, gap = 8;
65+ let top = back ? edge.top - h - gap : edge.bottom + gap;
66+ if (top + h > innerHeight - gap) top = all.top - h - gap;
67+ if (top < gap) top = Math.min(all.bottom + gap, innerHeight - h - gap);
68+ const x = back ? edge.left : edge.right;
69+ const left = Math.max(gap, Math.min(x - w / 2, innerWidth - w - gap));
70+ bar.style.top = `${Math.round(top)}px`;
71+ bar.style.left = `${Math.round(left)}px`;
72+ };
73+
74+ const update = () => {
75+ const now = current();
76+ for (const bar of document.querySelectorAll(BAR)) {
77+ if (bar !== now?.bar) bar.hidden = true;
78+ }
79+ if (!now) return;
80+ now.bar.hidden = false;
81+ place(now);
82+ };
83+
84+ // Text fragments only match whole words, so a selection that starts or
85+ // ends halfway through one would point at nothing. When it does, the
86+ // stray piece of word is left out of the fragment - the quote still says
87+ // what the reader chose.
88+ const WORD = /[\p{L}\p{N}]/u;
89+ const midWord = (node, offset) =>
90+ node.nodeType === Node.TEXT_NODE &&
91+ WORD.test(node.data.charAt(offset - 1)) && WORD.test(node.data.charAt(offset));
92+
93+ // -, & and , are the fragment syntax's own punctuation and have to be
94+ // escaped; encodeURIComponent already takes care of the other two.
95+ const enc = (s) => encodeURIComponent(s).replace(/-/g, "%2D");
96+
97+ const fragment = ({range, text}) => {
98+ // A fragment's start and end each have to sit within one block, so a
99+ // passage running over several paragraphs is named by its first and last.
100+ // split() with a capture group keeps the whitespace in between, so the
101+ // no-break spaces the build puts in reach the fragment as they are in the
102+ // page. Words sit at the even indexes. With a single line, first and last
103+ // are the same array, and both trims below apply to it.
104+ const lines = text.split(/\n+/).map((l) => l.trim()).filter(Boolean).map((l) => l.split(/(\s+)/));
105+ const first = lines[0], last = lines[lines.length - 1];
106+ if (midWord(range.startContainer, range.startOffset) && first.length > 1) first.splice(0, 2);
107+ if (midWord(range.endContainer, range.endOffset) && last.length > 1) last.splice(-2);
108+ if (lines.length === 1 && (first.length + 1) / 2 <= EXACT_WORDS) return enc(first.join(""));
109+ const start = first.slice(0, EDGE_WORDS * 2 - 1).join("");
110+ const end = last.slice(-(EDGE_WORDS * 2 - 1)).join("");
111+ return `${enc(start)},${enc(end)}`;
112+ };
113+
114+ // location, not <link rel="canonical">: hx-boost leaves the first page's
115+ // canonical behind in <head> for the rest of the visit.
116+ const link = (now) => `${location.origin}${location.pathname}#:~:text=${fragment(now)}`;
117+
118+ const graphemes = (s) =>
119+ typeof Intl.Segmenter === "function"
120+ ? [...new Intl.Segmenter().segment(s)].map((g) => g.segment)
121+ : [...s];
122+
123+ const post = (now) => {
124+ const {bar, text} = now;
125+ const open = bar.dataset.quoteOpen || "“", close = bar.dataset.quoteClose || "”";
126+ let quote = text.replace(/\s*\n\s*/g, "\n");
127+ const room = POST_LIMIT - LINK_ALLOWANCE - graphemes(open + close).length - 2;
128+ const g = graphemes(quote);
129+ if (g.length > room) quote = g.slice(0, room - 1).join("").trimEnd() + "…";
130+ return `${open}${quote}${close}\n\n${link(now)}`;
131+ };
132+
133+ // The same corner toast the feed page uses (style.scss's .copy-toast).
134+ const toast = (message) => {
135+ let el = document.querySelector(".copy-toast");
136+ if (!el) {
137+ el = document.createElement("div");
138+ el.className = "copy-toast";
139+ el.setAttribute("role", "status");
140+ document.body.appendChild(el);
141+ }
142+ el.textContent = message;
143+ el.classList.remove("is-on");
144+ void el.getBoundingClientRect().width;
145+ el.classList.add("is-on");
146+ clearTimeout(hide);
147+ hide = setTimeout(() => el.classList.remove("is-on"), SHOWN);
148+ };
149+
150+ const actions = {
151+ bluesky(now) {
152+ const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(post(now))}`;
153+ window.open(url, "_blank", "noopener,noreferrer");
154+ },
155+ async link(now) {
156+ try {
157+ await navigator.clipboard.writeText(link(now));
158+ toast(now.bar.dataset.quoteCopied || "Copied");
159+ } catch {
160+ // No clipboard (insecure context, refused permission): the page's
161+ // own address bar is the next best thing to copy from.
162+ history.replaceState(history.state, "", link(now));
163+ }
164+ },
165+ };
166+
167+ document.addEventListener("selectionchange", update);
168+ // Scrolling moves the selection and not the fixed bar, so the bar follows,
169+ // once per frame at most.
170+ const follow = () => {
171+ raf ||= requestAnimationFrame(() => { raf = 0; const now = current(); if (now && !now.bar.hidden) place(now); });
172+ };
173+ addEventListener("scroll", follow, {capture: true, passive: true});
174+ addEventListener("resize", follow);
175+
176+ // Pressing a button must not be what clears the selection it acts on.
177+ document.addEventListener("mousedown", (e) => {
178+ if (e.target.closest?.(BAR)) e.preventDefault();
179+ });
180+ document.addEventListener("click", (e) => {
181+ const button = e.target.closest?.(`${BAR} [data-quote-action]`);
182+ const now = button && current();
183+ if (now) actions[button.dataset.quoteAction]?.(now);
184+ });
185+ document.addEventListener("keydown", (e) => {
186+ if (e.key !== "Escape") return;
187+ for (const bar of document.querySelectorAll(BAR)) bar.hidden = true;
188+ });
189+})();
modified blog/i18n/en.toml +18 -0
@@ -53,6 +53,24 @@ other = "Click to copy"
5353 [feedCopied]
5454 other = "Copied"
5555
56+[quoteShare]
57+other = "Share this quote"
58+
59+[quoteShareVia]
60+other = "Share via"
61+
62+[quoteLink]
63+other = "Link"
64+
65+[quoteCopied]
66+other = "Link copied"
67+
68+[quoteOpen]
69+other = "“"
70+
71+[quoteClose]
72+other = "”"
73+
5674 [feedEmpty]
5775 other = "No posts in this feed yet."
5876
@@ -53,6 +53,24 @@ other = "Click to copy"
53 [feedCopied]53 [feedCopied]
54 other = "Copied"54 other = "Copied"
55 55
56+[quoteShare]
57+other = "Share this quote"
58+
59+[quoteShareVia]
60+other = "Share via"
61+
62+[quoteLink]
63+other = "Link"
64+
65+[quoteCopied]
66+other = "Link copied"
67+
68+[quoteOpen]
69+other = "“"
70+
71+[quoteClose]
72+other = "”"
73+
56 [feedEmpty]74 [feedEmpty]
57 other = "No posts in this feed yet."75 other = "No posts in this feed yet."
58 76
modified blog/i18n/es.toml +18 -0
@@ -53,6 +53,24 @@ other = "Clic para copiar"
5353 [feedCopied]
5454 other = "Copiado"
5555
56+[quoteShare]
57+other = "Compartir este fragmento"
58+
59+[quoteShareVia]
60+other = "Compartir vía"
61+
62+[quoteLink]
63+other = "Enlace"
64+
65+[quoteCopied]
66+other = "Enlace copiado"
67+
68+[quoteOpen]
69+other = "«"
70+
71+[quoteClose]
72+other = "»"
73+
5674 [feedEmpty]
5775 other = "Este canal todavía no tiene artículos."
5876
@@ -53,6 +53,24 @@ other = "Clic para copiar"
53 [feedCopied]53 [feedCopied]
54 other = "Copiado"54 other = "Copiado"
55 55
56+[quoteShare]
57+other = "Compartir este fragmento"
58+
59+[quoteShareVia]
60+other = "Compartir vía"
61+
62+[quoteLink]
63+other = "Enlace"
64+
65+[quoteCopied]
66+other = "Enlace copiado"
67+
68+[quoteOpen]
69+other = "«"
70+
71+[quoteClose]
72+other = "»"
73+
56 [feedEmpty]74 [feedEmpty]
57 other = "Este canal todavía no tiene artículos."75 other = "Este canal todavía no tiene artículos."
58 76
modified blog/i18n/fr.toml +18 -0
@@ -53,6 +53,24 @@ other = "Cliquer pour copier"
5353 [feedCopied]
5454 other = "Copié"
5555
56+[quoteShare]
57+other = "Partager cet extrait"
58+
59+[quoteShareVia]
60+other = "Partager via"
61+
62+[quoteLink]
63+other = "Lien"
64+
65+[quoteCopied]
66+other = "Lien copié"
67+
68+[quoteOpen]
69+other = "«\u00A0"
70+
71+[quoteClose]
72+other = "\u00A0»"
73+
5674 [feedEmpty]
5775 other = "Ce flux ne contient encore aucun article."
5876
@@ -53,6 +53,24 @@ other = "Cliquer pour copier"
53 [feedCopied]53 [feedCopied]
54 other = "Copié"54 other = "Copié"
55 55
56+[quoteShare]
57+other = "Partager cet extrait"
58+
59+[quoteShareVia]
60+other = "Partager via"
61+
62+[quoteLink]
63+other = "Lien"
64+
65+[quoteCopied]
66+other = "Lien copié"
67+
68+[quoteOpen]
69+other = "«\u00A0"
70+
71+[quoteClose]
72+other = "\u00A0»"
73+
56 [feedEmpty]74 [feedEmpty]
57 other = "Ce flux ne contient encore aucun article."75 other = "Ce flux ne contient encore aucun article."
58 76
modified blog/i18n/ja.toml +18 -0
@@ -52,6 +52,24 @@ other = "クリックでコピー"
5252 [feedCopied]
5353 other = "コピーしました"
5454
55+[quoteShare]
56+other = "この引用を共有"
57+
58+[quoteShareVia]
59+other = "共有先"
60+
61+[quoteLink]
62+other = "リンク"
63+
64+[quoteCopied]
65+other = "リンクをコピーしました"
66+
67+[quoteOpen]
68+other = "「"
69+
70+[quoteClose]
71+other = "」"
72+
5573 [feedEmpty]
5674 other = "このフィードにはまだ記事がありません。"
5775
@@ -52,6 +52,24 @@ other = "クリックでコピー"
52 [feedCopied]52 [feedCopied]
53 other = "コピーしました"53 other = "コピーしました"
54 54
55+[quoteShare]
56+other = "この引用を共有"
57+
58+[quoteShareVia]
59+other = "共有先"
60+
61+[quoteLink]
62+other = "リンク"
63+
64+[quoteCopied]
65+other = "リンクをコピーしました"
66+
67+[quoteOpen]
68+other = "「"
69+
70+[quoteClose]
71+other = "」"
72+
55 [feedEmpty]73 [feedEmpty]
56 other = "このフィードにはまだ記事がありません。"74 other = "このフィードにはまだ記事がありません。"
57 75
modified blog/i18n/pt.toml +18 -0
@@ -53,6 +53,24 @@ other = "Clique para copiar"
5353 [feedCopied]
5454 other = "Copiado"
5555
56+[quoteShare]
57+other = "Compartilhar este trecho"
58+
59+[quoteShareVia]
60+other = "Compartilhar via"
61+
62+[quoteLink]
63+other = "Link"
64+
65+[quoteCopied]
66+other = "Link copiado"
67+
68+[quoteOpen]
69+other = "“"
70+
71+[quoteClose]
72+other = "”"
73+
5674 [feedEmpty]
5775 other = "Este feed ainda não tem posts."
5876
@@ -53,6 +53,24 @@ other = "Clique para copiar"
53 [feedCopied]53 [feedCopied]
54 other = "Copiado"54 other = "Copiado"
55 55
56+[quoteShare]
57+other = "Compartilhar este trecho"
58+
59+[quoteShareVia]
60+other = "Compartilhar via"
61+
62+[quoteLink]
63+other = "Link"
64+
65+[quoteCopied]
66+other = "Link copiado"
67+
68+[quoteOpen]
69+other = "“"
70+
71+[quoteClose]
72+other = "”"
73+
56 [feedEmpty]74 [feedEmpty]
57 other = "Este feed ainda não tem posts."75 other = "Este feed ainda não tem posts."
58 76
modified blog/layouts/_default/baseof.html +6 -0
@@ -212,6 +212,12 @@
212212 {{ with resources.Get "js/hx-preload.min.js" | fingerprint }}
213213 <script src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" defer></script>
214214 {{ end }}
215+ {{- /* Loaded on every page, not just posts: hx-boost never reloads
216+ <head>, so a visit that starts on the home page reaches its posts with
217+ only the scripts the home page had. */}}
218+ {{ with resources.Get "js/quote-share.js" | minify | fingerprint }}
219+ <script src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" defer></script>
220+ {{ end }}
215221 {{- /*
216222 htmx's own browser back/forward handling (#restoreHistory) is a
217223 separate code path from a boosted click - it calls htmx.ajax() directly
@@ -212,6 +212,12 @@
212 {{ with resources.Get "js/hx-preload.min.js" | fingerprint }}212 {{ with resources.Get "js/hx-preload.min.js" | fingerprint }}
213 <script src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" defer></script>213 <script src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" defer></script>
214 {{ end }}214 {{ end }}
215+ {{- /* Loaded on every page, not just posts: hx-boost never reloads
216+ <head>, so a visit that starts on the home page reaches its posts with
217+ only the scripts the home page had. */}}
218+ {{ with resources.Get "js/quote-share.js" | minify | fingerprint }}
219+ <script src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" defer></script>
220+ {{ end }}
215 {{- /*221 {{- /*
216 htmx's own browser back/forward handling (#restoreHistory) is a222 htmx's own browser back/forward handling (#restoreHistory) is a
217 separate code path from a boosted click - it calls htmx.ajax() directly223 separate code path from a boosted click - it calls htmx.ajax() directly
modified blog/layouts/_default/single.html +22 -0
@@ -13,5 +13,27 @@
1313 </header>
1414
1515 <div class="post-body prose">{{ .Content }}</div>
16+
17+ {{- /*
18+ The bar js/quote-share.js shows next to a passage selected in the post:
19+ rendered here, hidden, so every word it says comes from this language's
20+ i18n file, and so a page without the script has nothing on it that does
21+ nothing. The quote marks are data rather than markup because they wrap
22+ the quote in the Bluesky post, not anything on this page.
23+ */}}
24+ <div class="quote-share" role="toolbar" aria-label="{{ i18n "quoteShare" }}" hidden
25+ data-quote-open="{{ i18n "quoteOpen" }}" data-quote-close="{{ i18n "quoteClose" }}"
26+ data-quote-copied="{{ i18n "quoteCopied" }}">
27+ <span class="quote-share__via">{{ i18n "quoteShareVia" }}</span>
28+ <button type="button" data-quote-action="bluesky">
29+ <svg viewBox="0 0 600 530" aria-hidden="true"><path fill="currentColor" d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/></svg>
30+ Bluesky
31+ </button>
32+ <span class="quote-share__sep" aria-hidden="true"></span>
33+ <button type="button" data-quote-action="link">
34+ <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
35+ {{ i18n "quoteLink" }}
36+ </button>
37+ </div>
1638 </article>
1739 {{ end }}
@@ -13,5 +13,27 @@
13 </header>13 </header>
14 14
15 <div class="post-body prose">{{ .Content }}</div>15 <div class="post-body prose">{{ .Content }}</div>
16+
17+ {{- /*
18+ The bar js/quote-share.js shows next to a passage selected in the post:
19+ rendered here, hidden, so every word it says comes from this language's
20+ i18n file, and so a page without the script has nothing on it that does
21+ nothing. The quote marks are data rather than markup because they wrap
22+ the quote in the Bluesky post, not anything on this page.
23+ */}}
24+ <div class="quote-share" role="toolbar" aria-label="{{ i18n "quoteShare" }}" hidden
25+ data-quote-open="{{ i18n "quoteOpen" }}" data-quote-close="{{ i18n "quoteClose" }}"
26+ data-quote-copied="{{ i18n "quoteCopied" }}">
27+ <span class="quote-share__via">{{ i18n "quoteShareVia" }}</span>
28+ <button type="button" data-quote-action="bluesky">
29+ <svg viewBox="0 0 600 530" aria-hidden="true"><path fill="currentColor" d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/></svg>
30+ Bluesky
31+ </button>
32+ <span class="quote-share__sep" aria-hidden="true"></span>
33+ <button type="button" data-quote-action="link">
34+ <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
35+ {{ i18n "quoteLink" }}
36+ </button>
37+ </div>
16 </article>38 </article>
17 {{ end }}39 {{ end }}