mykiwi/mykiwi.blogpublic Fork
⑂ bb5f529
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

Copy the feed URL by clicking the box it sits inUnverified

The address is the one thing on that page worth taking away, and
taking it away meant dragging across a line of monospace and hoping
the selection landed on the whole of it. Make the box around it the
button: the box is what reads as one thing to press, and the URL is a
thin line of type to aim at.

It is an upgrade, not a requirement. The markup ships as a box with a
URL in it, and the script is what turns that into a control - the
role, the tab stop, the name a screen reader reads, the pointer, and
the "click to copy" in the corner all arrive together, written by the
code that implements them. A page whose script never ran offers no
press it cannot honour; the URL is still selectable text, still
user-select:all, and still says the same thing.

What the clipboard cannot say for itself, the toast says: the word
"Copied", in the corner, gone a second later, taking nothing with it
on the way out because it never occupied any space. Behind it,
navigator.clipboard where it is allowed and execCommand over a
selection where it is not, and if even that fails the URL is left
selected rather than the page claiming a copy that did not happen.

The behaviour lives in one file that both renderings of the page pull
in - the stylesheet as a deferred <script src>, the standby inlined by
Hugo and called by hand once it has built the document - because the
page is already written twice and its one piece of behaviour need not
be. What it says is on the element it upgrades, in data attributes, so
no string is spelled out in the script and every language keeps its
own. It builds the toast with createElementNS: on the standby path
this runs inside index.xml, where createElement makes an element in no
namespace at all, and nothing renders.
Romain Gautier committed 2026-09-21T00:04:58+02:00 Browse files
bb5f529 parent: cf5bcc8
modified blog/assets/css/style.scss +75 -0
@@ -1376,6 +1376,7 @@ blockquote.alert {
13761376 }
13771377
13781378 .feed-address {
1379+ position: relative;
13791380 margin-top: 1.1rem;
13801381 background: var(--paper-raised);
13811382 border: 1px solid var(--rule);
@@ -1383,6 +1384,34 @@ blockquote.alert {
13831384 padding: 0.8rem 0.95rem;
13841385 }
13851386
1387+// The whole box is the button, and it only becomes one when js/feed-copy.js
1388+// has made it one: with no script this is a box with a URL in it, and it
1389+// should neither offer a press nor wear a pointer.
1390+.feed-address.is-copyable {
1391+ cursor: pointer;
1392+
1393+ &:hover {
1394+ border-color: var(--accent);
1395+ background: var(--glow);
1396+ }
1397+
1398+ &:hover .copy-hint {
1399+ color: var(--accent);
1400+ }
1401+}
1402+
1403+// The offer, in the corner opposite the label so the two read as one line.
1404+// Absolute, so turning up after the fact moves nothing that was already
1405+// there. Written by the script; see .feed-address.is-copyable above.
1406+.copy-hint {
1407+ @extend %chrome;
1408+ position: absolute;
1409+ top: 0.8rem;
1410+ right: 0.95rem;
1411+ font-size: 0.62rem;
1412+ color: var(--ink-faint);
1413+}
1414+
13861415 .feed-address__label {
13871416 @extend %chrome;
13881417 display: block;
@@ -1405,6 +1434,44 @@ blockquote.alert {
14051434 user-select: all;
14061435 }
14071436
1437+// The word the clipboard cannot say for itself, in the corner rather than
1438+// next to the address: it has to be visible from wherever the eye happens to
1439+// be, and it has to be gone a second later without moving anything.
1440+.copy-toast {
1441+ @extend %chrome;
1442+ position: fixed;
1443+ top: 1rem;
1444+ right: 1rem;
1445+ z-index: 10;
1446+ color: var(--accent);
1447+ background: var(--paper-raised);
1448+ border: 1px solid var(--rule-strong);
1449+ border-radius: 4px;
1450+ padding: 0.5rem 0.75rem;
1451+ box-shadow: 0 6px 18px #0000001f;
1452+ // Laid out from the start so the toast has a size to animate, and kept out
1453+ // of the way of clicks it sits over.
1454+ opacity: 0;
1455+ pointer-events: none;
1456+
1457+ &.is-on {
1458+ opacity: 1;
1459+ }
1460+}
1461+
1462+@media (prefers-reduced-motion: no-preference) {
1463+ .copy-toast {
1464+ transform: translateY(-0.4rem);
1465+ transition:
1466+ opacity 0.18s ease,
1467+ transform 0.18s cubic-bezier(0.22, 0.68, 0.35, 1);
1468+
1469+ &.is-on {
1470+ transform: none;
1471+ }
1472+ }
1473+}
1474+
14081475 .feed-grid {
14091476 display: grid;
14101477 grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
@@ -1503,6 +1570,14 @@ blockquote.alert {
15031570 }
15041571
15051572 @media (prefers-reduced-motion: no-preference) {
1573+ .feed-address.is-copyable,
1574+ .copy-hint {
1575+ transition:
1576+ border-color 0.2s ease,
1577+ background-color 0.2s ease,
1578+ color 0.2s ease;
1579+ }
1580+
15061581 .feed-card {
15071582 transition:
15081583 border-color 0.2s ease,
@@ -1376,6 +1376,7 @@ blockquote.alert {
1376 }1376 }
1377 1377
1378 .feed-address {1378 .feed-address {
1379+ position: relative;
1379 margin-top: 1.1rem;1380 margin-top: 1.1rem;
1380 background: var(--paper-raised);1381 background: var(--paper-raised);
1381 border: 1px solid var(--rule);1382 border: 1px solid var(--rule);
@@ -1383,6 +1384,34 @@ blockquote.alert {
1383 padding: 0.8rem 0.95rem;1384 padding: 0.8rem 0.95rem;
1384 }1385 }
1385 1386
1387+// The whole box is the button, and it only becomes one when js/feed-copy.js
1388+// has made it one: with no script this is a box with a URL in it, and it
1389+// should neither offer a press nor wear a pointer.
1390+.feed-address.is-copyable {
1391+ cursor: pointer;
1392+
1393+ &:hover {
1394+ border-color: var(--accent);
1395+ background: var(--glow);
1396+ }
1397+
1398+ &:hover .copy-hint {
1399+ color: var(--accent);
1400+ }
1401+}
1402+
1403+// The offer, in the corner opposite the label so the two read as one line.
1404+// Absolute, so turning up after the fact moves nothing that was already
1405+// there. Written by the script; see .feed-address.is-copyable above.
1406+.copy-hint {
1407+ @extend %chrome;
1408+ position: absolute;
1409+ top: 0.8rem;
1410+ right: 0.95rem;
1411+ font-size: 0.62rem;
1412+ color: var(--ink-faint);
1413+}
1414+
1386 .feed-address__label {1415 .feed-address__label {
1387 @extend %chrome;1416 @extend %chrome;
1388 display: block;1417 display: block;
@@ -1405,6 +1434,44 @@ blockquote.alert {
1405 user-select: all;1434 user-select: all;
1406 }1435 }
1407 1436
1437+// The word the clipboard cannot say for itself, in the corner rather than
1438+// next to the address: it has to be visible from wherever the eye happens to
1439+// be, and it has to be gone a second later without moving anything.
1440+.copy-toast {
1441+ @extend %chrome;
1442+ position: fixed;
1443+ top: 1rem;
1444+ right: 1rem;
1445+ z-index: 10;
1446+ color: var(--accent);
1447+ background: var(--paper-raised);
1448+ border: 1px solid var(--rule-strong);
1449+ border-radius: 4px;
1450+ padding: 0.5rem 0.75rem;
1451+ box-shadow: 0 6px 18px #0000001f;
1452+ // Laid out from the start so the toast has a size to animate, and kept out
1453+ // of the way of clicks it sits over.
1454+ opacity: 0;
1455+ pointer-events: none;
1456+
1457+ &.is-on {
1458+ opacity: 1;
1459+ }
1460+}
1461+
1462+@media (prefers-reduced-motion: no-preference) {
1463+ .copy-toast {
1464+ transform: translateY(-0.4rem);
1465+ transition:
1466+ opacity 0.18s ease,
1467+ transform 0.18s cubic-bezier(0.22, 0.68, 0.35, 1);
1468+
1469+ &.is-on {
1470+ transform: none;
1471+ }
1472+ }
1473+}
1474+
1408 .feed-grid {1475 .feed-grid {
1409 display: grid;1476 display: grid;
1410 grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));1477 grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
@@ -1503,6 +1570,14 @@ blockquote.alert {
1503 }1570 }
1504 1571
1505 @media (prefers-reduced-motion: no-preference) {1572 @media (prefers-reduced-motion: no-preference) {
1573+ .feed-address.is-copyable,
1574+ .copy-hint {
1575+ transition:
1576+ border-color 0.2s ease,
1577+ background-color 0.2s ease,
1578+ color 0.2s ease;
1579+ }
1580+
1506 .feed-card {1581 .feed-card {
1507 transition:1582 transition:
1508 border-color 0.2s ease,1583 border-color 0.2s ease,
added blog/assets/js/feed-copy.js +137 -0
new file mode 100644
@@ -0,0 +1,137 @@
1+/*
2+ Click the feed address, get it on the clipboard.
3+
4+ The feed page is built twice over - _default/home.feedstyle.xsl in XSLT and
5+ _default/home.feedscript.js in DOM calls - so this, the one piece of
6+ behaviour it has, is kept in a file of its own rather than written out
7+ twice: the stylesheet pulls it in as a deferred <script src>, and the
8+ standby has Hugo inline it and calls wire() itself once the page exists.
9+
10+ Everything it needs to say is on the element it upgrades - data-copy for
11+ the text to copy, data-copied for the word the toast uses, data-copy-label
12+ for the name a screen reader reads, data-copy-hint for the one in the
13+ corner - so no string is spelled out here and the page stays in its own
14+ language.
15+
16+ It is an upgrade and not a requirement: the address is plain, selectable
17+ text that says the same thing with none of this running.
18+*/
19+(() => {
20+ const XHTML = "http://www.w3.org/1999/xhtml";
21+ const SHOWN = 1900;
22+ let hide;
23+
24+ // createElementNS, not createElement: on the standby path this runs inside
25+ // index.xml, an XML document, where createElement would put the toast in no
26+ // namespace at all - an unknown element that renders nothing.
27+ const toast = (doc, message) => {
28+ let el = doc.querySelector(".copy-toast");
29+ if (!el) {
30+ el = doc.createElementNS(XHTML, "div");
31+ el.setAttribute("class", "copy-toast");
32+ // status, not alert: a confirmation worth speaking after whatever the
33+ // reader is already hearing, not over the top of it.
34+ el.setAttribute("role", "status");
35+ doc.body.appendChild(el);
36+ }
37+ el.textContent = message;
38+ // Drop the class and read a layout property back before setting it again,
39+ // so a second click inside the first toast's life restarts the animation
40+ // instead of leaving it mid-flight.
41+ el.classList.remove("is-on");
42+ void el.getBoundingClientRect().width;
43+ el.classList.add("is-on");
44+ clearTimeout(hide);
45+ hide = setTimeout(() => el.classList.remove("is-on"), SHOWN);
46+ };
47+
48+ // The text worth selecting is the <code> holding it, not the whole box: the
49+ // box also holds a label and the hint, and a reader finishing the job by
50+ // hand with ctrl-C should get the URL and nothing else.
51+ const source = (el) => el.querySelector("code") || el;
52+
53+ // Selecting is both the fallback the copy below works through and the state
54+ // to leave behind when nothing worked - which is what a click on the
55+ // address did before any of this existed.
56+ const select = (doc, el) => {
57+ const range = doc.createRange();
58+ range.selectNodeContents(el);
59+ const selection = doc.defaultView.getSelection();
60+ selection.removeAllRanges();
61+ selection.addRange(range);
62+ };
63+
64+ const write = async (doc, el, text) => {
65+ // The clipboard API is the right one but it is not always there: it wants
66+ // a secure context, and the permission can be refused.
67+ try {
68+ await doc.defaultView.navigator.clipboard.writeText(text);
69+ return true;
70+ } catch {}
71+ // execCommand is deprecated and still the only other way. It copies the
72+ // selection, so make the URL the selection - which is also a fair place
73+ // to leave the page if the command turns out to be gone too.
74+ try {
75+ select(doc, source(el));
76+ return doc.execCommand("copy");
77+ } catch {
78+ return false;
79+ }
80+ };
81+
82+ const wire = (doc) => {
83+ for (const el of doc.querySelectorAll("[data-copy]")) {
84+ if (el.hasAttribute("data-copy-wired")) continue;
85+ el.setAttribute("data-copy-wired", "");
86+
87+ // Only now does this become something to press: the attributes that
88+ // make it a control are set by the script that implements the control,
89+ // so it is never announced as a button that does nothing.
90+ el.setAttribute("role", "button");
91+ el.setAttribute("tabindex", "0");
92+ const label = el.getAttribute("data-copy-label");
93+ if (label) el.setAttribute("aria-label", label);
94+ el.classList.add("is-copyable");
95+
96+ // The offer, written in the corner of the thing that makes it. It is
97+ // put here rather than in the markup for the same reason as the role
98+ // above: with this script missing there is nothing to click, and a page
99+ // that says otherwise is worse than a page that says nothing. Hidden
100+ // from screen readers, which have the aria-label saying the same thing.
101+ const hint = el.getAttribute("data-copy-hint");
102+ if (hint) {
103+ const tip = el.ownerDocument.createElementNS(XHTML, "span");
104+ tip.setAttribute("class", "copy-hint");
105+ tip.setAttribute("aria-hidden", "true");
106+ tip.textContent = hint;
107+ el.appendChild(tip);
108+ }
109+
110+ const run = async () => {
111+ const text = (el.getAttribute("data-copy") || "").trim();
112+ if (!text) return;
113+ if (await write(doc, el, text)) toast(doc, el.getAttribute("data-copied") || text);
114+ else select(doc, source(el));
115+ };
116+
117+ el.addEventListener("click", run);
118+ // A <code> is not a button, so the keys a button answers to are wired
119+ // by hand. preventDefault keeps Space from scrolling the page.
120+ el.addEventListener("keydown", (event) => {
121+ if (event.key !== "Enter" && event.key !== " ") return;
122+ event.preventDefault();
123+ run();
124+ });
125+ }
126+ };
127+
128+ // The stylesheet's page is finished the moment it is parsed, so this run is
129+ // the one that wires it. The standby's page does not exist yet at this
130+ // point and it calls wire() again once it does.
131+ globalThis.wireFeedCopy = wire;
132+ if (document.readyState === "loading") {
133+ document.addEventListener("DOMContentLoaded", () => wire(document), {once: true});
134+ } else {
135+ wire(document);
136+ }
137+})();
new file mode 100644
@@ -0,0 +1,137 @@
1+/*
2+ Click the feed address, get it on the clipboard.
3+
4+ The feed page is built twice over - _default/home.feedstyle.xsl in XSLT and
5+ _default/home.feedscript.js in DOM calls - so this, the one piece of
6+ behaviour it has, is kept in a file of its own rather than written out
7+ twice: the stylesheet pulls it in as a deferred <script src>, and the
8+ standby has Hugo inline it and calls wire() itself once the page exists.
9+
10+ Everything it needs to say is on the element it upgrades - data-copy for
11+ the text to copy, data-copied for the word the toast uses, data-copy-label
12+ for the name a screen reader reads, data-copy-hint for the one in the
13+ corner - so no string is spelled out here and the page stays in its own
14+ language.
15+
16+ It is an upgrade and not a requirement: the address is plain, selectable
17+ text that says the same thing with none of this running.
18+*/
19+(() => {
20+ const XHTML = "http://www.w3.org/1999/xhtml";
21+ const SHOWN = 1900;
22+ let hide;
23+
24+ // createElementNS, not createElement: on the standby path this runs inside
25+ // index.xml, an XML document, where createElement would put the toast in no
26+ // namespace at all - an unknown element that renders nothing.
27+ const toast = (doc, message) => {
28+ let el = doc.querySelector(".copy-toast");
29+ if (!el) {
30+ el = doc.createElementNS(XHTML, "div");
31+ el.setAttribute("class", "copy-toast");
32+ // status, not alert: a confirmation worth speaking after whatever the
33+ // reader is already hearing, not over the top of it.
34+ el.setAttribute("role", "status");
35+ doc.body.appendChild(el);
36+ }
37+ el.textContent = message;
38+ // Drop the class and read a layout property back before setting it again,
39+ // so a second click inside the first toast's life restarts the animation
40+ // instead of leaving it mid-flight.
41+ el.classList.remove("is-on");
42+ void el.getBoundingClientRect().width;
43+ el.classList.add("is-on");
44+ clearTimeout(hide);
45+ hide = setTimeout(() => el.classList.remove("is-on"), SHOWN);
46+ };
47+
48+ // The text worth selecting is the <code> holding it, not the whole box: the
49+ // box also holds a label and the hint, and a reader finishing the job by
50+ // hand with ctrl-C should get the URL and nothing else.
51+ const source = (el) => el.querySelector("code") || el;
52+
53+ // Selecting is both the fallback the copy below works through and the state
54+ // to leave behind when nothing worked - which is what a click on the
55+ // address did before any of this existed.
56+ const select = (doc, el) => {
57+ const range = doc.createRange();
58+ range.selectNodeContents(el);
59+ const selection = doc.defaultView.getSelection();
60+ selection.removeAllRanges();
61+ selection.addRange(range);
62+ };
63+
64+ const write = async (doc, el, text) => {
65+ // The clipboard API is the right one but it is not always there: it wants
66+ // a secure context, and the permission can be refused.
67+ try {
68+ await doc.defaultView.navigator.clipboard.writeText(text);
69+ return true;
70+ } catch {}
71+ // execCommand is deprecated and still the only other way. It copies the
72+ // selection, so make the URL the selection - which is also a fair place
73+ // to leave the page if the command turns out to be gone too.
74+ try {
75+ select(doc, source(el));
76+ return doc.execCommand("copy");
77+ } catch {
78+ return false;
79+ }
80+ };
81+
82+ const wire = (doc) => {
83+ for (const el of doc.querySelectorAll("[data-copy]")) {
84+ if (el.hasAttribute("data-copy-wired")) continue;
85+ el.setAttribute("data-copy-wired", "");
86+
87+ // Only now does this become something to press: the attributes that
88+ // make it a control are set by the script that implements the control,
89+ // so it is never announced as a button that does nothing.
90+ el.setAttribute("role", "button");
91+ el.setAttribute("tabindex", "0");
92+ const label = el.getAttribute("data-copy-label");
93+ if (label) el.setAttribute("aria-label", label);
94+ el.classList.add("is-copyable");
95+
96+ // The offer, written in the corner of the thing that makes it. It is
97+ // put here rather than in the markup for the same reason as the role
98+ // above: with this script missing there is nothing to click, and a page
99+ // that says otherwise is worse than a page that says nothing. Hidden
100+ // from screen readers, which have the aria-label saying the same thing.
101+ const hint = el.getAttribute("data-copy-hint");
102+ if (hint) {
103+ const tip = el.ownerDocument.createElementNS(XHTML, "span");
104+ tip.setAttribute("class", "copy-hint");
105+ tip.setAttribute("aria-hidden", "true");
106+ tip.textContent = hint;
107+ el.appendChild(tip);
108+ }
109+
110+ const run = async () => {
111+ const text = (el.getAttribute("data-copy") || "").trim();
112+ if (!text) return;
113+ if (await write(doc, el, text)) toast(doc, el.getAttribute("data-copied") || text);
114+ else select(doc, source(el));
115+ };
116+
117+ el.addEventListener("click", run);
118+ // A <code> is not a button, so the keys a button answers to are wired
119+ // by hand. preventDefault keeps Space from scrolling the page.
120+ el.addEventListener("keydown", (event) => {
121+ if (event.key !== "Enter" && event.key !== " ") return;
122+ event.preventDefault();
123+ run();
124+ });
125+ }
126+ };
127+
128+ // The stylesheet's page is finished the moment it is parsed, so this run is
129+ // the one that wires it. The standby's page does not exist yet at this
130+ // point and it calls wire() again once it does.
131+ globalThis.wireFeedCopy = wire;
132+ if (document.readyState === "loading") {
133+ document.addEventListener("DOMContentLoaded", () => wire(document), {once: true});
134+ } else {
135+ wire(document);
136+ }
137+})();
modified blog/i18n/en.toml +9 -0
@@ -35,6 +35,15 @@ other = "You are looking at the RSS feed for {{ .Site }} — the whole archive,
3535 [feedURL]
3636 other = "Feed URL"
3737
38+[feedCopy]
39+other = "Copy the feed URL"
40+
41+[feedCopyHint]
42+other = "Click to copy"
43+
44+[feedCopied]
45+other = "Copied"
46+
3847 [feedEmpty]
3948 other = "No posts in this feed yet."
4049
@@ -35,6 +35,15 @@ other = "You are looking at the RSS feed for {{ .Site }} — the whole archive,
35 [feedURL]35 [feedURL]
36 other = "Feed URL"36 other = "Feed URL"
37 37
38+[feedCopy]
39+other = "Copy the feed URL"
40+
41+[feedCopyHint]
42+other = "Click to copy"
43+
44+[feedCopied]
45+other = "Copied"
46+
38 [feedEmpty]47 [feedEmpty]
39 other = "No posts in this feed yet."48 other = "No posts in this feed yet."
40 49
modified blog/i18n/es.toml +9 -0
@@ -35,6 +35,15 @@ other = "Estás viendo el canal RSS de {{ .Site }} — todo el archivo, en un fo
3535 [feedURL]
3636 other = "URL del canal"
3737
38+[feedCopy]
39+other = "Copiar la URL del canal"
40+
41+[feedCopyHint]
42+other = "Clic para copiar"
43+
44+[feedCopied]
45+other = "Copiado"
46+
3847 [feedEmpty]
3948 other = "Este canal todavía no tiene artículos."
4049
@@ -35,6 +35,15 @@ other = "Estás viendo el canal RSS de {{ .Site }} — todo el archivo, en un fo
35 [feedURL]35 [feedURL]
36 other = "URL del canal"36 other = "URL del canal"
37 37
38+[feedCopy]
39+other = "Copiar la URL del canal"
40+
41+[feedCopyHint]
42+other = "Clic para copiar"
43+
44+[feedCopied]
45+other = "Copiado"
46+
38 [feedEmpty]47 [feedEmpty]
39 other = "Este canal todavía no tiene artículos."48 other = "Este canal todavía no tiene artículos."
40 49
modified blog/i18n/fr.toml +9 -0
@@ -35,6 +35,15 @@ other = "Vous consultez le flux RSS de {{ .Site }} — toutes les publications,
3535 [feedURL]
3636 other = "URL du flux"
3737
38+[feedCopy]
39+other = "Copier l’URL du flux"
40+
41+[feedCopyHint]
42+other = "Cliquer pour copier"
43+
44+[feedCopied]
45+other = "Copié"
46+
3847 [feedEmpty]
3948 other = "Ce flux ne contient encore aucun article."
4049
@@ -35,6 +35,15 @@ other = "Vous consultez le flux RSS de {{ .Site }} — toutes les publications,
35 [feedURL]35 [feedURL]
36 other = "URL du flux"36 other = "URL du flux"
37 37
38+[feedCopy]
39+other = "Copier l’URL du flux"
40+
41+[feedCopyHint]
42+other = "Cliquer pour copier"
43+
44+[feedCopied]
45+other = "Copié"
46+
38 [feedEmpty]47 [feedEmpty]
39 other = "Ce flux ne contient encore aucun article."48 other = "Ce flux ne contient encore aucun article."
40 49
modified blog/i18n/ja.toml +9 -0
@@ -34,6 +34,15 @@ other = "これは{{ .Site }}のRSSフィードです。すべての記事を、
3434 [feedURL]
3535 other = "フィードのURL"
3636
37+[feedCopy]
38+other = "フィードのURLをコピー"
39+
40+[feedCopyHint]
41+other = "クリックでコピー"
42+
43+[feedCopied]
44+other = "コピーしました"
45+
3746 [feedEmpty]
3847 other = "このフィードにはまだ記事がありません。"
3948
@@ -34,6 +34,15 @@ other = "これは{{ .Site }}のRSSフィードです。すべての記事を、
34 [feedURL]34 [feedURL]
35 other = "フィードのURL"35 other = "フィードのURL"
36 36
37+[feedCopy]
38+other = "フィードのURLをコピー"
39+
40+[feedCopyHint]
41+other = "クリックでコピー"
42+
43+[feedCopied]
44+other = "コピーしました"
45+
37 [feedEmpty]46 [feedEmpty]
38 other = "このフィードにはまだ記事がありません。"47 other = "このフィードにはまだ記事がありません。"
39 48
modified blog/i18n/pt.toml +9 -0
@@ -35,6 +35,15 @@ other = "Você está vendo o feed RSS de {{ .Site }} — todo o arquivo, num for
3535 [feedURL]
3636 other = "URL do feed"
3737
38+[feedCopy]
39+other = "Copiar a URL do feed"
40+
41+[feedCopyHint]
42+other = "Clique para copiar"
43+
44+[feedCopied]
45+other = "Copiado"
46+
3847 [feedEmpty]
3948 other = "Este feed ainda não tem posts."
4049
@@ -35,6 +35,15 @@ other = "Você está vendo o feed RSS de {{ .Site }} — todo o arquivo, num for
35 [feedURL]35 [feedURL]
36 other = "URL do feed"36 other = "URL do feed"
37 37
38+[feedCopy]
39+other = "Copiar a URL do feed"
40+
41+[feedCopyHint]
42+other = "Clique para copiar"
43+
44+[feedCopied]
45+other = "Copiado"
46+
38 [feedEmpty]47 [feedEmpty]
39 other = "Este feed ainda não tem posts."48 other = "Este feed ainda não tem posts."
40 49
modified blog/layouts/_default/home.feedscript.js +18 -1
@@ -36,12 +36,20 @@
3636 "pageTitle" (i18n "feedPageTitle")
3737 "intro" (i18n "feedIntro" (dict "Site" site.Title))
3838 "url" (i18n "feedURL")
39+ "copied" (i18n "feedCopied")
40+ "copyLabel" (i18n "feedCopy")
41+ "copyHint" (i18n "feedCopyHint")
3942 "readMore" (i18n "readMore")
4043 "empty" (i18n "feedEmpty")
4144 "backHome" (i18n "feedBackHome" (dict "Site" site.Title))
4245 "licenseText" (i18n "licenseText")
4346 "licenseURL" (i18n "licenseURL")
4447 }}
48+{{- /* The same file _default/home.feedstyle.xsl loads as a <script src>,
49+ inlined here instead: this page is assembled after the parse, so there is
50+ nothing for a deferred script to wire, and render() calls wireFeedCopy at
51+ the end once the document is the page. */}}
52+{{ with resources.Get "js/feed-copy.js" }}{{ .Content }}{{ end }}
4553 (() => {
4654 const C = {{ $cfg | jsonify }};
4755 const SVG = "http://www.w3.org/2000/svg";
@@ -164,15 +172,23 @@
164172
165173 const intro = make("div", "feed-intro rise");
166174 intro.setAttribute("style", "--i: 1");
175+ // The whole box is the button, not just the URL inside it, and data-copy
176+ // carries the URL rather than letting the script read the box's text -
177+ // the label and the hint live in there too.
167178 const address = make("div", "feed-address");
168179 // The channel's own atom:self if it has one, so a feed served from a new
169180 // address advertises the new one. getElementsByTagNameNS because a CSS
170181 // selector cannot name the Atom namespace.
171182 const self = [...src.getElementsByTagNameNS(ATOM, "link")]
172183 .find((l) => l.getAttribute("rel") === "self")?.getAttribute("href");
184+ const url = self || C.feed;
185+ address.setAttribute("data-copy", url);
186+ address.setAttribute("data-copied", C.copied);
187+ address.setAttribute("data-copy-label", C.copyLabel);
188+ address.setAttribute("data-copy-hint", C.copyHint);
173189 address.append(
174190 make("span", "feed-address__label", C.url),
175- make("code", "feed-address__url", self || C.feed),
191+ make("code", "feed-address__url", url),
176192 );
177193 intro.append(make("p", "feed-intro__text", C.intro), address);
178194 page.appendChild(intro);
@@ -243,6 +259,7 @@
243259 wrap.appendChild(foot);
244260
245261 src.replaceChild(src.importNode(out.documentElement, true), src.documentElement);
262+ globalThis.wireFeedCopy(src);
246263 };
247264
248265 // A script in an XML document runs the moment the parser reaches it, which
@@ -36,12 +36,20 @@
36 "pageTitle" (i18n "feedPageTitle")36 "pageTitle" (i18n "feedPageTitle")
37 "intro" (i18n "feedIntro" (dict "Site" site.Title))37 "intro" (i18n "feedIntro" (dict "Site" site.Title))
38 "url" (i18n "feedURL")38 "url" (i18n "feedURL")
39+ "copied" (i18n "feedCopied")
40+ "copyLabel" (i18n "feedCopy")
41+ "copyHint" (i18n "feedCopyHint")
39 "readMore" (i18n "readMore")42 "readMore" (i18n "readMore")
40 "empty" (i18n "feedEmpty")43 "empty" (i18n "feedEmpty")
41 "backHome" (i18n "feedBackHome" (dict "Site" site.Title))44 "backHome" (i18n "feedBackHome" (dict "Site" site.Title))
42 "licenseText" (i18n "licenseText")45 "licenseText" (i18n "licenseText")
43 "licenseURL" (i18n "licenseURL")46 "licenseURL" (i18n "licenseURL")
44 }}47 }}
48+{{- /* The same file _default/home.feedstyle.xsl loads as a <script src>,
49+ inlined here instead: this page is assembled after the parse, so there is
50+ nothing for a deferred script to wire, and render() calls wireFeedCopy at
51+ the end once the document is the page. */}}
52+{{ with resources.Get "js/feed-copy.js" }}{{ .Content }}{{ end }}
45 (() => {53 (() => {
46 const C = {{ $cfg | jsonify }};54 const C = {{ $cfg | jsonify }};
47 const SVG = "http://www.w3.org/2000/svg";55 const SVG = "http://www.w3.org/2000/svg";
@@ -164,15 +172,23 @@
164 172
165 const intro = make("div", "feed-intro rise");173 const intro = make("div", "feed-intro rise");
166 intro.setAttribute("style", "--i: 1");174 intro.setAttribute("style", "--i: 1");
175+ // The whole box is the button, not just the URL inside it, and data-copy
176+ // carries the URL rather than letting the script read the box's text -
177+ // the label and the hint live in there too.
167 const address = make("div", "feed-address");178 const address = make("div", "feed-address");
168 // The channel's own atom:self if it has one, so a feed served from a new179 // The channel's own atom:self if it has one, so a feed served from a new
169 // address advertises the new one. getElementsByTagNameNS because a CSS180 // address advertises the new one. getElementsByTagNameNS because a CSS
170 // selector cannot name the Atom namespace.181 // selector cannot name the Atom namespace.
171 const self = [...src.getElementsByTagNameNS(ATOM, "link")]182 const self = [...src.getElementsByTagNameNS(ATOM, "link")]
172 .find((l) => l.getAttribute("rel") === "self")?.getAttribute("href");183 .find((l) => l.getAttribute("rel") === "self")?.getAttribute("href");
184+ const url = self || C.feed;
185+ address.setAttribute("data-copy", url);
186+ address.setAttribute("data-copied", C.copied);
187+ address.setAttribute("data-copy-label", C.copyLabel);
188+ address.setAttribute("data-copy-hint", C.copyHint);
173 address.append(189 address.append(
174 make("span", "feed-address__label", C.url),190 make("span", "feed-address__label", C.url),
175- make("code", "feed-address__url", self || C.feed),191+ make("code", "feed-address__url", url),
176 );192 );
177 intro.append(make("p", "feed-intro__text", C.intro), address);193 intro.append(make("p", "feed-intro__text", C.intro), address);
178 page.appendChild(intro);194 page.appendChild(intro);
@@ -243,6 +259,7 @@
243 wrap.appendChild(foot);259 wrap.appendChild(foot);
244 260
245 src.replaceChild(src.importNode(out.documentElement, true), src.documentElement);261 src.replaceChild(src.importNode(out.documentElement, true), src.documentElement);
262+ globalThis.wireFeedCopy(src);
246 };263 };
247 264
248 // A script in an XML document runs the moment the parser reaches it, which265 // A script in an XML document runs the moment the parser reaches it, which
modified blog/layouts/_default/home.feedstyle.xsl +32 -12
@@ -28,6 +28,12 @@
2828 {{- $css = .RelPermalink }}
2929 {{- $integrity = .Data.Integrity }}
3030 {{- end }}
31+{{- end }}
32+{{- $js := "" }}
33+{{- $jsIntegrity := "" }}
34+{{- with resources.Get "js/feed-copy.js" | fingerprint }}
35+ {{- $js = .RelPermalink }}
36+ {{- $jsIntegrity = .Data.Integrity }}
3137 {{- end -}}
3238 {{- /* -}} above, not }}: an XML declaration has to be the document's very
3339 first byte, and the newline this action would otherwise leave behind is
@@ -57,6 +63,12 @@
5763 {{- with $css }}
5864 <link rel="stylesheet" type="text/css" href="{{ transform.XMLEscape . }}"{{ with $integrity }} integrity="{{ transform.XMLEscape . }}"{{ end }}/>
5965 {{- end }}
66+ {{- /* The feed address, made clickable. Deferred, so it runs with the
67+ transform's output already in the document and has the address to
68+ wire; home.feedscript.js inlines the same file instead. */}}
69+ {{- with $js }}
70+ <script src="{{ transform.XMLEscape . }}"{{ with $jsIntegrity }} integrity="{{ transform.XMLEscape . }}"{{ end }} defer="defer"></script>
71+ {{- end }}
6072 </head>
6173 <body>
6274 <div class="wrap">
@@ -96,19 +108,27 @@
96108
97109 <div class="feed-intro rise" style="--i: 1">
98110 <p class="feed-intro__text">{{ transform.XMLEscape (i18n "feedIntro" (dict "Site" site.Title)) }}</p>
99- <div class="feed-address">
111+ {{- /* The channel's own atom:self, not a value baked in here:
112+ if the feed is ever served from somewhere else, the URL on the
113+ page follows it instead of quietly going stale. Held in a
114+ variable because it is needed twice now: once to read, once
115+ for the clipboard. */}}
116+ <xsl:variable name="self" select="/rss/channel/*[local-name() = 'link' and @rel = 'self']/@href"/>
117+ <xsl:variable name="url">
118+ <xsl:choose>
119+ <xsl:when test="$self"><xsl:value-of select="$self"/></xsl:when>
120+ <xsl:otherwise>{{ transform.XMLEscape $feed }}</xsl:otherwise>
121+ </xsl:choose>
122+ </xsl:variable>
123+ {{- /* The whole box is the button, not just the URL inside it:
124+ the box is what reads as one thing to press, and the URL is a
125+ thin line of type to aim at. data-copy carries the URL rather
126+ than letting the script read the text, so the label and the
127+ hint sitting in the same box cannot end up on the clipboard
128+ with it. */}}
129+ <div class="feed-address" data-copy="{$url}" data-copied="{{ transform.XMLEscape (i18n "feedCopied") }}" data-copy-label="{{ transform.XMLEscape (i18n "feedCopy") }}" data-copy-hint="{{ transform.XMLEscape (i18n "feedCopyHint") }}">
100130 <span class="feed-address__label">{{ transform.XMLEscape (i18n "feedURL") }}</span>
101- {{- /* The channel's own atom:self, not a value baked in here:
102- if the feed is ever served from somewhere else, the URL on
103- the page follows it instead of quietly going stale. */}}
104- <code class="feed-address__url">
105- <xsl:choose>
106- <xsl:when test="/rss/channel/*[local-name() = 'link' and @rel = 'self']/@href">
107- <xsl:value-of select="/rss/channel/*[local-name() = 'link' and @rel = 'self']/@href"/>
108- </xsl:when>
109- <xsl:otherwise>{{ transform.XMLEscape $feed }}</xsl:otherwise>
110- </xsl:choose>
111- </code>
131+ <code class="feed-address__url"><xsl:value-of select="$url"/></code>
112132 </div>
113133 </div>
114134
@@ -28,6 +28,12 @@
28 {{- $css = .RelPermalink }}28 {{- $css = .RelPermalink }}
29 {{- $integrity = .Data.Integrity }}29 {{- $integrity = .Data.Integrity }}
30 {{- end }}30 {{- end }}
31+{{- end }}
32+{{- $js := "" }}
33+{{- $jsIntegrity := "" }}
34+{{- with resources.Get "js/feed-copy.js" | fingerprint }}
35+ {{- $js = .RelPermalink }}
36+ {{- $jsIntegrity = .Data.Integrity }}
31 {{- end -}}37 {{- end -}}
32 {{- /* -}} above, not }}: an XML declaration has to be the document's very38 {{- /* -}} above, not }}: an XML declaration has to be the document's very
33 first byte, and the newline this action would otherwise leave behind is39 first byte, and the newline this action would otherwise leave behind is
@@ -57,6 +63,12 @@
57 {{- with $css }}63 {{- with $css }}
58 <link rel="stylesheet" type="text/css" href="{{ transform.XMLEscape . }}"{{ with $integrity }} integrity="{{ transform.XMLEscape . }}"{{ end }}/>64 <link rel="stylesheet" type="text/css" href="{{ transform.XMLEscape . }}"{{ with $integrity }} integrity="{{ transform.XMLEscape . }}"{{ end }}/>
59 {{- end }}65 {{- end }}
66+ {{- /* The feed address, made clickable. Deferred, so it runs with the
67+ transform's output already in the document and has the address to
68+ wire; home.feedscript.js inlines the same file instead. */}}
69+ {{- with $js }}
70+ <script src="{{ transform.XMLEscape . }}"{{ with $jsIntegrity }} integrity="{{ transform.XMLEscape . }}"{{ end }} defer="defer"></script>
71+ {{- end }}
60 </head>72 </head>
61 <body>73 <body>
62 <div class="wrap">74 <div class="wrap">
@@ -96,19 +108,27 @@
96 108
97 <div class="feed-intro rise" style="--i: 1">109 <div class="feed-intro rise" style="--i: 1">
98 <p class="feed-intro__text">{{ transform.XMLEscape (i18n "feedIntro" (dict "Site" site.Title)) }}</p>110 <p class="feed-intro__text">{{ transform.XMLEscape (i18n "feedIntro" (dict "Site" site.Title)) }}</p>
99- <div class="feed-address">111+ {{- /* The channel's own atom:self, not a value baked in here:
112+ if the feed is ever served from somewhere else, the URL on the
113+ page follows it instead of quietly going stale. Held in a
114+ variable because it is needed twice now: once to read, once
115+ for the clipboard. */}}
116+ <xsl:variable name="self" select="/rss/channel/*[local-name() = 'link' and @rel = 'self']/@href"/>
117+ <xsl:variable name="url">
118+ <xsl:choose>
119+ <xsl:when test="$self"><xsl:value-of select="$self"/></xsl:when>
120+ <xsl:otherwise>{{ transform.XMLEscape $feed }}</xsl:otherwise>
121+ </xsl:choose>
122+ </xsl:variable>
123+ {{- /* The whole box is the button, not just the URL inside it:
124+ the box is what reads as one thing to press, and the URL is a
125+ thin line of type to aim at. data-copy carries the URL rather
126+ than letting the script read the text, so the label and the
127+ hint sitting in the same box cannot end up on the clipboard
128+ with it. */}}
129+ <div class="feed-address" data-copy="{$url}" data-copied="{{ transform.XMLEscape (i18n "feedCopied") }}" data-copy-label="{{ transform.XMLEscape (i18n "feedCopy") }}" data-copy-hint="{{ transform.XMLEscape (i18n "feedCopyHint") }}">
100 <span class="feed-address__label">{{ transform.XMLEscape (i18n "feedURL") }}</span>130 <span class="feed-address__label">{{ transform.XMLEscape (i18n "feedURL") }}</span>
101- {{- /* The channel's own atom:self, not a value baked in here:131+ <code class="feed-address__url"><xsl:value-of select="$url"/></code>
102- if the feed is ever served from somewhere else, the URL on
103- the page follows it instead of quietly going stale. */}}
104- <code class="feed-address__url">
105- <xsl:choose>
106- <xsl:when test="/rss/channel/*[local-name() = 'link' and @rel = 'self']/@href">
107- <xsl:value-of select="/rss/channel/*[local-name() = 'link' and @rel = 'self']/@href"/>
108- </xsl:when>
109- <xsl:otherwise>{{ transform.XMLEscape $feed }}</xsl:otherwise>
110- </xsl:choose>
111- </code>
112 </div>132 </div>
113 </div>133 </div>
114 134