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.
bb5f529 parent: cf5bcc8 modified
blog/assets/css/style.scss +75 -0 | @@ -1376,6 +1376,7 @@ blockquote.alert { | ||
| 1376 | 1376 | } |
| 1377 | 1377 | |
| 1378 | 1378 | .feed-address { |
| 1379 | + position: relative; | |
| 1379 | 1380 | margin-top: 1.1rem; |
| 1380 | 1381 | background: var(--paper-raised); |
| 1381 | 1382 | border: 1px solid var(--rule); |
| @@ -1383,6 +1384,34 @@ blockquote.alert { | ||
| 1383 | 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 | 1415 | .feed-address__label { |
| 1387 | 1416 | @extend %chrome; |
| 1388 | 1417 | display: block; |
| @@ -1405,6 +1434,44 @@ blockquote.alert { | ||
| 1405 | 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 | 1475 | .feed-grid { |
| 1409 | 1476 | display: grid; |
| 1410 | 1477 | grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); |
| @@ -1503,6 +1570,14 @@ blockquote.alert { | ||
| 1503 | 1570 | } |
| 1504 | 1571 | |
| 1505 | 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 | 1581 | .feed-card { |
| 1507 | 1582 | transition: |
| 1508 | 1583 | 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, | ||
| 35 | 35 | [feedURL] |
| 36 | 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 | 47 | [feedEmpty] |
| 39 | 48 | other = "No posts in this feed yet." |
| 40 | 49 | |
| @@ -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 | ||
| 35 | 35 | [feedURL] |
| 36 | 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 | 47 | [feedEmpty] |
| 39 | 48 | other = "Este canal todavía no tiene artículos." |
| 40 | 49 | |
| @@ -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, | ||
| 35 | 35 | [feedURL] |
| 36 | 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 | 47 | [feedEmpty] |
| 39 | 48 | other = "Ce flux ne contient encore aucun article." |
| 40 | 49 | |
| @@ -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フィードです。すべての記事を、 | ||
| 34 | 34 | [feedURL] |
| 35 | 35 | other = "フィードのURL" |
| 36 | 36 | |
| 37 | +[feedCopy] | |
| 38 | +other = "フィードのURLをコピー" | |
| 39 | + | |
| 40 | +[feedCopyHint] | |
| 41 | +other = "クリックでコピー" | |
| 42 | + | |
| 43 | +[feedCopied] | |
| 44 | +other = "コピーしました" | |
| 45 | + | |
| 37 | 46 | [feedEmpty] |
| 38 | 47 | other = "このフィードにはまだ記事がありません。" |
| 39 | 48 | |
| @@ -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 | ||
| 35 | 35 | [feedURL] |
| 36 | 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 | 47 | [feedEmpty] |
| 39 | 48 | other = "Este feed ainda não tem posts." |
| 40 | 49 | |
| @@ -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 @@ | ||
| 36 | 36 | "pageTitle" (i18n "feedPageTitle") |
| 37 | 37 | "intro" (i18n "feedIntro" (dict "Site" site.Title)) |
| 38 | 38 | "url" (i18n "feedURL") |
| 39 | + "copied" (i18n "feedCopied") | |
| 40 | + "copyLabel" (i18n "feedCopy") | |
| 41 | + "copyHint" (i18n "feedCopyHint") | |
| 39 | 42 | "readMore" (i18n "readMore") |
| 40 | 43 | "empty" (i18n "feedEmpty") |
| 41 | 44 | "backHome" (i18n "feedBackHome" (dict "Site" site.Title)) |
| 42 | 45 | "licenseText" (i18n "licenseText") |
| 43 | 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 | 54 | const C = {{ $cfg | jsonify }}; |
| 47 | 55 | const SVG = "http://www.w3.org/2000/svg"; |
| @@ -164,15 +172,23 @@ | ||
| 164 | 172 | |
| 165 | 173 | const intro = make("div", "feed-intro rise"); |
| 166 | 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 | 178 | const address = make("div", "feed-address"); |
| 168 | 179 | // The channel's own atom:self if it has one, so a feed served from a new |
| 169 | 180 | // address advertises the new one. getElementsByTagNameNS because a CSS |
| 170 | 181 | // selector cannot name the Atom namespace. |
| 171 | 182 | const self = [...src.getElementsByTagNameNS(ATOM, "link")] |
| 172 | 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 | 189 | address.append( |
| 174 | 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 | 193 | intro.append(make("p", "feed-intro__text", C.intro), address); |
| 178 | 194 | page.appendChild(intro); |
| @@ -243,6 +259,7 @@ | ||
| 243 | 259 | wrap.appendChild(foot); |
| 244 | 260 | |
| 245 | 261 | src.replaceChild(src.importNode(out.documentElement, true), src.documentElement); |
| 262 | + globalThis.wireFeedCopy(src); | |
| 246 | 263 | }; |
| 247 | 264 | |
| 248 | 265 | // 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 new | 179 | // 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 CSS | 180 | // 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, which | 265 | // 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 @@ | ||
| 28 | 28 | {{- $css = .RelPermalink }} |
| 29 | 29 | {{- $integrity = .Data.Integrity }} |
| 30 | 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 | 37 | {{- end -}} |
| 32 | 38 | {{- /* -}} above, not }}: an XML declaration has to be the document's very |
| 33 | 39 | first byte, and the newline this action would otherwise leave behind is |
| @@ -57,6 +63,12 @@ | ||
| 57 | 63 | {{- with $css }} |
| 58 | 64 | <link rel="stylesheet" type="text/css" href="{{ transform.XMLEscape . }}"{{ with $integrity }} integrity="{{ transform.XMLEscape . }}"{{ end }}/> |
| 59 | 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 | 72 | </head> |
| 61 | 73 | <body> |
| 62 | 74 | <div class="wrap"> |
| @@ -96,19 +108,27 @@ | ||
| 96 | 108 | |
| 97 | 109 | <div class="feed-intro rise" style="--i: 1"> |
| 98 | 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 | 130 | <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> | |
| 112 | 132 | </div> |
| 113 | 133 | </div> |
| 114 | 134 | |
| @@ -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 very | 38 | {{- /* -}} above, not }}: an XML declaration has to be the document's very |
| 33 | first byte, and the newline this action would otherwise leave behind is | 39 | 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 | ||