Render the feed as a page, not a tag treeUnverified
A browser handed /index.xml shows what the file is - a tag tree, under a banner explaining that it has no style information - which is the one page on the site that greets a reader with an apology. It is also the page a curious reader is most likely to land on by accident, since every page links to the feed and not every click on that link comes from a feed reader. Point the feed at a stylesheet of its own. The transform runs in the browser, off the XML already in hand, and builds the same masthead, type and palette as the rest of the site: the feed's address up front to hand to a reader, then the posts it carries. Nothing a subscriber sees changes - <?xml-stylesheet?> is a processing instruction feed readers have always ignored, and the bytes of the feed are otherwise the same. XSLT is on its way out: Chrome removes it on 2026-11-17 (chromestatus 4709671889534976), and the tag tree would come back with it. So the page is written twice - once in XSLT, once in DOM calls - and the standby is embedded in the feed as an XHTML-namespaced <script>, which a browser that ran the transform never copies into its output and a browser that did not runs instead. The two files are meant to be read side by side, and they emit the same class names, which is what lets one section of style.scss serve whichever of them ran. Both are Hugo output formats rather than files in static/, so each language build gets its own: its own translated labels, its own feed address, and the fingerprinted stylesheet URL for that build.
cf5bcc8 parent: 61ebed5 modified
blog/assets/css/style.scss +184 -0 | @@ -1331,6 +1331,190 @@ blockquote.alert { | ||
| 1331 | 1331 | } |
| 1332 | 1332 | } |
| 1333 | 1333 | |
| 1334 | +// --- Feed page ------------------------------------------------------------- | |
| 1335 | +// Styles for the feed rendered as a page, which is not a Hugo layout at all: | |
| 1336 | +// layouts/_default/home.feedstyle.xsl (and its standby, home.feedscript.js) | |
| 1337 | +// emit this markup from the RSS itself, inside the browser. Both are written | |
| 1338 | +// against the class names below, so renaming one means editing three files. | |
| 1339 | +// | |
| 1340 | +// It sits in the site's ordinary .wrap/.column, at the same measure as a post, | |
| 1341 | +// so the feed page lines up with every other page rather than announcing | |
| 1342 | +// itself as a different kind of thing. That measure is what sizes the grid | |
| 1343 | +// below: two cards fit across it, one on a phone. | |
| 1344 | + | |
| 1345 | +.feed-head { | |
| 1346 | + padding-top: calc(var(--step) * 0.5); | |
| 1347 | + margin-bottom: calc(var(--step) * 1.3); | |
| 1348 | +} | |
| 1349 | + | |
| 1350 | +// The RSS mark, set into the title rather than in a kicker line above it: | |
| 1351 | +// the icon already says "feed", so saying it again in words was one line of | |
| 1352 | +// chrome the page did not need. Sized in em and left on the baseline, it | |
| 1353 | +// lines up with the cap height at every step of the title's clamp. | |
| 1354 | +.feed-title__mark { | |
| 1355 | + width: 0.74em; | |
| 1356 | + height: 0.74em; | |
| 1357 | + margin-right: 0.18em; | |
| 1358 | + fill: var(--accent); | |
| 1359 | +} | |
| 1360 | + | |
| 1361 | +// Blurb, then the address you actually came for. Stacked rather than set | |
| 1362 | +// side by side: the column is the measure wide whatever the window does, so a | |
| 1363 | +// second track would squeeze the prose at every screen size instead of only | |
| 1364 | +// the narrow ones. | |
| 1365 | +.feed-intro { | |
| 1366 | + margin-bottom: calc(var(--step) * 1.6); | |
| 1367 | + padding-bottom: calc(var(--step) * 1.1); | |
| 1368 | + border-bottom: 1px solid var(--rule); | |
| 1369 | +} | |
| 1370 | + | |
| 1371 | +.feed-intro__text { | |
| 1372 | + color: var(--ink-muted); | |
| 1373 | + font-size: 1.02rem; | |
| 1374 | + line-height: 1.62; | |
| 1375 | + text-wrap: pretty; | |
| 1376 | +} | |
| 1377 | + | |
| 1378 | +.feed-address { | |
| 1379 | + margin-top: 1.1rem; | |
| 1380 | + background: var(--paper-raised); | |
| 1381 | + border: 1px solid var(--rule); | |
| 1382 | + border-radius: 4px; | |
| 1383 | + padding: 0.8rem 0.95rem; | |
| 1384 | +} | |
| 1385 | + | |
| 1386 | +.feed-address__label { | |
| 1387 | + @extend %chrome; | |
| 1388 | + display: block; | |
| 1389 | + font-size: 0.62rem; | |
| 1390 | + margin-bottom: 0.45rem; | |
| 1391 | +} | |
| 1392 | + | |
| 1393 | +// A feed URL is one long unbreakable token; let it wrap rather than widen the | |
| 1394 | +// card, and keep it selectable as the single thing worth copying here. | |
| 1395 | +.feed-address__url { | |
| 1396 | + display: block; | |
| 1397 | + font-family: var(--mono); | |
| 1398 | + font-size: 0.76rem; | |
| 1399 | + line-height: 1.5; | |
| 1400 | + color: var(--ink); | |
| 1401 | + overflow-wrap: anywhere; | |
| 1402 | + background: none; | |
| 1403 | + border: 0; | |
| 1404 | + padding: 0; | |
| 1405 | + user-select: all; | |
| 1406 | +} | |
| 1407 | + | |
| 1408 | +.feed-grid { | |
| 1409 | + display: grid; | |
| 1410 | + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); | |
| 1411 | + gap: 1rem; | |
| 1412 | +} | |
| 1413 | + | |
| 1414 | +.feed-card { | |
| 1415 | + position: relative; | |
| 1416 | + display: flex; | |
| 1417 | + flex-direction: column; | |
| 1418 | + padding: 1.15rem 1.25rem 1.2rem; | |
| 1419 | + background: var(--paper-raised); | |
| 1420 | + border: 1px solid var(--rule); | |
| 1421 | + border-radius: 4px; | |
| 1422 | + | |
| 1423 | + &:hover { | |
| 1424 | + border-color: var(--accent); | |
| 1425 | + background: var(--glow); | |
| 1426 | + } | |
| 1427 | +} | |
| 1428 | + | |
| 1429 | +.feed-card__date { | |
| 1430 | + @extend %chrome; | |
| 1431 | + font-size: 0.66rem; | |
| 1432 | + margin-bottom: 0.55rem; | |
| 1433 | +} | |
| 1434 | + | |
| 1435 | +.feed-card__title { | |
| 1436 | + font-size: 1.22rem; | |
| 1437 | + font-weight: 600; | |
| 1438 | + line-height: 1.22; | |
| 1439 | + letter-spacing: -0.015em; | |
| 1440 | + text-wrap: balance; | |
| 1441 | + | |
| 1442 | + a { | |
| 1443 | + color: inherit; | |
| 1444 | + text-decoration: none; | |
| 1445 | + | |
| 1446 | + // Stretched link: the whole card is the hit area, same trick as | |
| 1447 | + // .index-lead .title on the homepage. | |
| 1448 | + &::after { | |
| 1449 | + content: ""; | |
| 1450 | + position: absolute; | |
| 1451 | + inset: 0; | |
| 1452 | + } | |
| 1453 | + } | |
| 1454 | +} | |
| 1455 | + | |
| 1456 | +.feed-card:hover .feed-card__title a { | |
| 1457 | + color: var(--accent); | |
| 1458 | +} | |
| 1459 | + | |
| 1460 | +// A summary is whatever the post's front matter or Hugo's auto-teaser made | |
| 1461 | +// it, so this holds arbitrary prose markup rather than a single line. | |
| 1462 | +.feed-card__excerpt { | |
| 1463 | + color: var(--ink-muted); | |
| 1464 | + font-size: 0.9rem; | |
| 1465 | + line-height: 1.55; | |
| 1466 | + margin-top: 0.5rem; | |
| 1467 | + text-wrap: pretty; | |
| 1468 | + | |
| 1469 | + > * + * { | |
| 1470 | + margin-top: 0.5em; | |
| 1471 | + } | |
| 1472 | + | |
| 1473 | + // The card's stretched link covers everything; lift a link in the summary | |
| 1474 | + // back above it so it stays clickable in its own right. | |
| 1475 | + a { | |
| 1476 | + position: relative; | |
| 1477 | + z-index: 1; | |
| 1478 | + color: inherit; | |
| 1479 | + text-decoration-color: var(--rule-strong); | |
| 1480 | + text-underline-offset: 0.15em; | |
| 1481 | + | |
| 1482 | + &:hover { | |
| 1483 | + color: var(--accent); | |
| 1484 | + text-decoration-color: var(--accent); | |
| 1485 | + } | |
| 1486 | + } | |
| 1487 | +} | |
| 1488 | + | |
| 1489 | +// margin-top:auto rather than a margin on the excerpt above it, so the row of | |
| 1490 | +// "Read" links sits on one line across the grid however uneven the cards are. | |
| 1491 | +.feed-card__more { | |
| 1492 | + @extend %chrome; | |
| 1493 | + color: var(--accent); | |
| 1494 | + margin-top: auto; | |
| 1495 | + padding-top: 0.85rem; | |
| 1496 | +} | |
| 1497 | + | |
| 1498 | +// Nothing in the feed to show - an empty <channel>, or a feed reader's idea of | |
| 1499 | +// one. Better than a bare grid with no explanation. | |
| 1500 | +.feed-empty { | |
| 1501 | + color: var(--ink-faint); | |
| 1502 | + font-style: italic; | |
| 1503 | +} | |
| 1504 | + | |
| 1505 | +@media (prefers-reduced-motion: no-preference) { | |
| 1506 | + .feed-card { | |
| 1507 | + transition: | |
| 1508 | + border-color 0.2s ease, | |
| 1509 | + background-color 0.2s ease, | |
| 1510 | + transform 0.2s cubic-bezier(0.22, 0.68, 0.35, 1); | |
| 1511 | + } | |
| 1512 | + | |
| 1513 | + .feed-card:hover { | |
| 1514 | + transform: translateY(-2px); | |
| 1515 | + } | |
| 1516 | +} | |
| 1517 | + | |
| 1334 | 1518 | // --- Motion -------------------------------------------------------------- |
| 1335 | 1519 | |
| 1336 | 1520 | @media (prefers-reduced-motion: no-preference) { |
| @@ -1331,6 +1331,190 @@ blockquote.alert { | |||
| 1331 | } | 1331 | } |
| 1332 | } | 1332 | } |
| 1333 | 1333 | ||
| 1334 | +// --- Feed page ------------------------------------------------------------- | ||
| 1335 | +// Styles for the feed rendered as a page, which is not a Hugo layout at all: | ||
| 1336 | +// layouts/_default/home.feedstyle.xsl (and its standby, home.feedscript.js) | ||
| 1337 | +// emit this markup from the RSS itself, inside the browser. Both are written | ||
| 1338 | +// against the class names below, so renaming one means editing three files. | ||
| 1339 | +// | ||
| 1340 | +// It sits in the site's ordinary .wrap/.column, at the same measure as a post, | ||
| 1341 | +// so the feed page lines up with every other page rather than announcing | ||
| 1342 | +// itself as a different kind of thing. That measure is what sizes the grid | ||
| 1343 | +// below: two cards fit across it, one on a phone. | ||
| 1344 | + | ||
| 1345 | +.feed-head { | ||
| 1346 | + padding-top: calc(var(--step) * 0.5); | ||
| 1347 | + margin-bottom: calc(var(--step) * 1.3); | ||
| 1348 | +} | ||
| 1349 | + | ||
| 1350 | +// The RSS mark, set into the title rather than in a kicker line above it: | ||
| 1351 | +// the icon already says "feed", so saying it again in words was one line of | ||
| 1352 | +// chrome the page did not need. Sized in em and left on the baseline, it | ||
| 1353 | +// lines up with the cap height at every step of the title's clamp. | ||
| 1354 | +.feed-title__mark { | ||
| 1355 | + width: 0.74em; | ||
| 1356 | + height: 0.74em; | ||
| 1357 | + margin-right: 0.18em; | ||
| 1358 | + fill: var(--accent); | ||
| 1359 | +} | ||
| 1360 | + | ||
| 1361 | +// Blurb, then the address you actually came for. Stacked rather than set | ||
| 1362 | +// side by side: the column is the measure wide whatever the window does, so a | ||
| 1363 | +// second track would squeeze the prose at every screen size instead of only | ||
| 1364 | +// the narrow ones. | ||
| 1365 | +.feed-intro { | ||
| 1366 | + margin-bottom: calc(var(--step) * 1.6); | ||
| 1367 | + padding-bottom: calc(var(--step) * 1.1); | ||
| 1368 | + border-bottom: 1px solid var(--rule); | ||
| 1369 | +} | ||
| 1370 | + | ||
| 1371 | +.feed-intro__text { | ||
| 1372 | + color: var(--ink-muted); | ||
| 1373 | + font-size: 1.02rem; | ||
| 1374 | + line-height: 1.62; | ||
| 1375 | + text-wrap: pretty; | ||
| 1376 | +} | ||
| 1377 | + | ||
| 1378 | +.feed-address { | ||
| 1379 | + margin-top: 1.1rem; | ||
| 1380 | + background: var(--paper-raised); | ||
| 1381 | + border: 1px solid var(--rule); | ||
| 1382 | + border-radius: 4px; | ||
| 1383 | + padding: 0.8rem 0.95rem; | ||
| 1384 | +} | ||
| 1385 | + | ||
| 1386 | +.feed-address__label { | ||
| 1387 | + @extend %chrome; | ||
| 1388 | + display: block; | ||
| 1389 | + font-size: 0.62rem; | ||
| 1390 | + margin-bottom: 0.45rem; | ||
| 1391 | +} | ||
| 1392 | + | ||
| 1393 | +// A feed URL is one long unbreakable token; let it wrap rather than widen the | ||
| 1394 | +// card, and keep it selectable as the single thing worth copying here. | ||
| 1395 | +.feed-address__url { | ||
| 1396 | + display: block; | ||
| 1397 | + font-family: var(--mono); | ||
| 1398 | + font-size: 0.76rem; | ||
| 1399 | + line-height: 1.5; | ||
| 1400 | + color: var(--ink); | ||
| 1401 | + overflow-wrap: anywhere; | ||
| 1402 | + background: none; | ||
| 1403 | + border: 0; | ||
| 1404 | + padding: 0; | ||
| 1405 | + user-select: all; | ||
| 1406 | +} | ||
| 1407 | + | ||
| 1408 | +.feed-grid { | ||
| 1409 | + display: grid; | ||
| 1410 | + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); | ||
| 1411 | + gap: 1rem; | ||
| 1412 | +} | ||
| 1413 | + | ||
| 1414 | +.feed-card { | ||
| 1415 | + position: relative; | ||
| 1416 | + display: flex; | ||
| 1417 | + flex-direction: column; | ||
| 1418 | + padding: 1.15rem 1.25rem 1.2rem; | ||
| 1419 | + background: var(--paper-raised); | ||
| 1420 | + border: 1px solid var(--rule); | ||
| 1421 | + border-radius: 4px; | ||
| 1422 | + | ||
| 1423 | + &:hover { | ||
| 1424 | + border-color: var(--accent); | ||
| 1425 | + background: var(--glow); | ||
| 1426 | + } | ||
| 1427 | +} | ||
| 1428 | + | ||
| 1429 | +.feed-card__date { | ||
| 1430 | + @extend %chrome; | ||
| 1431 | + font-size: 0.66rem; | ||
| 1432 | + margin-bottom: 0.55rem; | ||
| 1433 | +} | ||
| 1434 | + | ||
| 1435 | +.feed-card__title { | ||
| 1436 | + font-size: 1.22rem; | ||
| 1437 | + font-weight: 600; | ||
| 1438 | + line-height: 1.22; | ||
| 1439 | + letter-spacing: -0.015em; | ||
| 1440 | + text-wrap: balance; | ||
| 1441 | + | ||
| 1442 | + a { | ||
| 1443 | + color: inherit; | ||
| 1444 | + text-decoration: none; | ||
| 1445 | + | ||
| 1446 | + // Stretched link: the whole card is the hit area, same trick as | ||
| 1447 | + // .index-lead .title on the homepage. | ||
| 1448 | + &::after { | ||
| 1449 | + content: ""; | ||
| 1450 | + position: absolute; | ||
| 1451 | + inset: 0; | ||
| 1452 | + } | ||
| 1453 | + } | ||
| 1454 | +} | ||
| 1455 | + | ||
| 1456 | +.feed-card:hover .feed-card__title a { | ||
| 1457 | + color: var(--accent); | ||
| 1458 | +} | ||
| 1459 | + | ||
| 1460 | +// A summary is whatever the post's front matter or Hugo's auto-teaser made | ||
| 1461 | +// it, so this holds arbitrary prose markup rather than a single line. | ||
| 1462 | +.feed-card__excerpt { | ||
| 1463 | + color: var(--ink-muted); | ||
| 1464 | + font-size: 0.9rem; | ||
| 1465 | + line-height: 1.55; | ||
| 1466 | + margin-top: 0.5rem; | ||
| 1467 | + text-wrap: pretty; | ||
| 1468 | + | ||
| 1469 | + > * + * { | ||
| 1470 | + margin-top: 0.5em; | ||
| 1471 | + } | ||
| 1472 | + | ||
| 1473 | + // The card's stretched link covers everything; lift a link in the summary | ||
| 1474 | + // back above it so it stays clickable in its own right. | ||
| 1475 | + a { | ||
| 1476 | + position: relative; | ||
| 1477 | + z-index: 1; | ||
| 1478 | + color: inherit; | ||
| 1479 | + text-decoration-color: var(--rule-strong); | ||
| 1480 | + text-underline-offset: 0.15em; | ||
| 1481 | + | ||
| 1482 | + &:hover { | ||
| 1483 | + color: var(--accent); | ||
| 1484 | + text-decoration-color: var(--accent); | ||
| 1485 | + } | ||
| 1486 | + } | ||
| 1487 | +} | ||
| 1488 | + | ||
| 1489 | +// margin-top:auto rather than a margin on the excerpt above it, so the row of | ||
| 1490 | +// "Read" links sits on one line across the grid however uneven the cards are. | ||
| 1491 | +.feed-card__more { | ||
| 1492 | + @extend %chrome; | ||
| 1493 | + color: var(--accent); | ||
| 1494 | + margin-top: auto; | ||
| 1495 | + padding-top: 0.85rem; | ||
| 1496 | +} | ||
| 1497 | + | ||
| 1498 | +// Nothing in the feed to show - an empty <channel>, or a feed reader's idea of | ||
| 1499 | +// one. Better than a bare grid with no explanation. | ||
| 1500 | +.feed-empty { | ||
| 1501 | + color: var(--ink-faint); | ||
| 1502 | + font-style: italic; | ||
| 1503 | +} | ||
| 1504 | + | ||
| 1505 | +@media (prefers-reduced-motion: no-preference) { | ||
| 1506 | + .feed-card { | ||
| 1507 | + transition: | ||
| 1508 | + border-color 0.2s ease, | ||
| 1509 | + background-color 0.2s ease, | ||
| 1510 | + transform 0.2s cubic-bezier(0.22, 0.68, 0.35, 1); | ||
| 1511 | + } | ||
| 1512 | + | ||
| 1513 | + .feed-card:hover { | ||
| 1514 | + transform: translateY(-2px); | ||
| 1515 | + } | ||
| 1516 | +} | ||
| 1517 | + | ||
| 1334 | // --- Motion -------------------------------------------------------------- | 1518 | // --- Motion -------------------------------------------------------------- |
| 1335 | 1519 | ||
| 1336 | @media (prefers-reduced-motion: no-preference) { | 1520 | @media (prefers-reduced-motion: no-preference) { |
modified
blog/hugo.toml +43 -0 | @@ -39,6 +39,49 @@ defaultContentLanguage = "en" | ||
| 39 | 39 | [minify] |
| 40 | 40 | disableSVG = true |
| 41 | 41 | |
| 42 | +# A browser handed an .xml URL shows its tag tree and a "this XML file does not | |
| 43 | +# appear to have any style information" banner. The <?xml-stylesheet?> in | |
| 44 | +# layouts/_default/rss.xml points at feed.xsl (below) so it renders a real page | |
| 45 | +# instead - the same masthead, type and palette as the rest of the site. Feed | |
| 46 | +# readers ignore that processing instruction entirely, so nothing a subscriber | |
| 47 | +# sees changes. | |
| 48 | +# | |
| 49 | +# Chrome removes XSLT on 2026-11-17 (chromestatus 4709671889534976), at which | |
| 50 | +# point feed.xsl stops being applied there and the tag tree comes back. feed.js | |
| 51 | +# is the standby: rss.xml embeds it as an XHTML-namespaced <script>, which runs | |
| 52 | +# only when the transform did not happen, and rebuilds the same page from the | |
| 53 | +# XML already in the document. While XSLT still works the script is simply | |
| 54 | +# never copied into the transform's output, so it costs nothing. | |
| 55 | +# | |
| 56 | +# Both are Hugo templates rather than files in static/, so each language build | |
| 57 | +# gets its own copy: translated labels, its own baseURL in the feed address, | |
| 58 | +# and the fingerprinted stylesheet URL for that build. | |
| 59 | +[mediaTypes."text/xsl"] | |
| 60 | +suffixes = ["xsl"] | |
| 61 | + | |
| 62 | +[outputFormats.feedstyle] | |
| 63 | +mediaType = "text/xsl" | |
| 64 | +baseName = "feed" | |
| 65 | +# Go's text/template, not html/template: the contextual escaper knows HTML, and | |
| 66 | +# an XSLT stylesheet is neither HTML nor a context it can reason about - left on | |
| 67 | +# it rewrites attribute values inside xsl:* elements. Escaping here is explicit. | |
| 68 | +isPlainText = true | |
| 69 | +# Keeps it out of .AlternativeOutputFormats, so baseof.html's <link | |
| 70 | +# rel="alternate"> loop does not start advertising a stylesheet as a feed. | |
| 71 | +notAlternative = true | |
| 72 | + | |
| 73 | +[outputFormats.feedscript] | |
| 74 | +mediaType = "text/javascript" | |
| 75 | +baseName = "feed" | |
| 76 | +isPlainText = true | |
| 77 | +notAlternative = true | |
| 78 | + | |
| 79 | +[outputs] | |
| 80 | +# html and rss are the defaults for the home page and have to be repeated - | |
| 81 | +# naming the key at all replaces the list rather than adding to it. Other page | |
| 82 | +# kinds are untouched, so sections keep their own index.xml. | |
| 83 | +home = ["html", "rss", "feedstyle", "feedscript"] | |
| 84 | + | |
| 42 | 85 | [permalinks] |
| 43 | 86 | [permalinks.page] |
| 44 | 87 | posts = "/:year/:month/:day/:slug/" |
| @@ -39,6 +39,49 @@ defaultContentLanguage = "en" | |||
| 39 | [minify] | 39 | [minify] |
| 40 | disableSVG = true | 40 | disableSVG = true |
| 41 | 41 | ||
| 42 | +# A browser handed an .xml URL shows its tag tree and a "this XML file does not | ||
| 43 | +# appear to have any style information" banner. The <?xml-stylesheet?> in | ||
| 44 | +# layouts/_default/rss.xml points at feed.xsl (below) so it renders a real page | ||
| 45 | +# instead - the same masthead, type and palette as the rest of the site. Feed | ||
| 46 | +# readers ignore that processing instruction entirely, so nothing a subscriber | ||
| 47 | +# sees changes. | ||
| 48 | +# | ||
| 49 | +# Chrome removes XSLT on 2026-11-17 (chromestatus 4709671889534976), at which | ||
| 50 | +# point feed.xsl stops being applied there and the tag tree comes back. feed.js | ||
| 51 | +# is the standby: rss.xml embeds it as an XHTML-namespaced <script>, which runs | ||
| 52 | +# only when the transform did not happen, and rebuilds the same page from the | ||
| 53 | +# XML already in the document. While XSLT still works the script is simply | ||
| 54 | +# never copied into the transform's output, so it costs nothing. | ||
| 55 | +# | ||
| 56 | +# Both are Hugo templates rather than files in static/, so each language build | ||
| 57 | +# gets its own copy: translated labels, its own baseURL in the feed address, | ||
| 58 | +# and the fingerprinted stylesheet URL for that build. | ||
| 59 | +[mediaTypes."text/xsl"] | ||
| 60 | +suffixes = ["xsl"] | ||
| 61 | + | ||
| 62 | +[outputFormats.feedstyle] | ||
| 63 | +mediaType = "text/xsl" | ||
| 64 | +baseName = "feed" | ||
| 65 | +# Go's text/template, not html/template: the contextual escaper knows HTML, and | ||
| 66 | +# an XSLT stylesheet is neither HTML nor a context it can reason about - left on | ||
| 67 | +# it rewrites attribute values inside xsl:* elements. Escaping here is explicit. | ||
| 68 | +isPlainText = true | ||
| 69 | +# Keeps it out of .AlternativeOutputFormats, so baseof.html's <link | ||
| 70 | +# rel="alternate"> loop does not start advertising a stylesheet as a feed. | ||
| 71 | +notAlternative = true | ||
| 72 | + | ||
| 73 | +[outputFormats.feedscript] | ||
| 74 | +mediaType = "text/javascript" | ||
| 75 | +baseName = "feed" | ||
| 76 | +isPlainText = true | ||
| 77 | +notAlternative = true | ||
| 78 | + | ||
| 79 | +[outputs] | ||
| 80 | +# html and rss are the defaults for the home page and have to be repeated - | ||
| 81 | +# naming the key at all replaces the list rather than adding to it. Other page | ||
| 82 | +# kinds are untouched, so sections keep their own index.xml. | ||
| 83 | +home = ["html", "rss", "feedstyle", "feedscript"] | ||
| 84 | + | ||
| 42 | [permalinks] | 85 | [permalinks] |
| 43 | [permalinks.page] | 86 | [permalinks.page] |
| 44 | posts = "/:year/:month/:day/:slug/" | 87 | posts = "/:year/:month/:day/:slug/" |
modified
blog/i18n/en.toml +15 -0 | @@ -26,6 +26,21 @@ other = "Subscribe via RSS" | ||
| 26 | 26 | [rssLabel] |
| 27 | 27 | other = "RSS" |
| 28 | 28 | |
| 29 | +[feedPageTitle] | |
| 30 | +other = "RSS feed" | |
| 31 | + | |
| 32 | +[feedIntro] | |
| 33 | +other = "You are looking at the RSS feed for {{ .Site }} — the whole archive, in a format made for software rather than for reading here. Hand the address below to a feed reader and every new post turns up on its own, with no algorithm in between." | |
| 34 | + | |
| 35 | +[feedURL] | |
| 36 | +other = "Feed URL" | |
| 37 | + | |
| 38 | +[feedEmpty] | |
| 39 | +other = "No posts in this feed yet." | |
| 40 | + | |
| 41 | +[feedBackHome] | |
| 42 | +other = "Back to {{ .Site }}" | |
| 43 | + | |
| 29 | 44 | [latest] |
| 30 | 45 | other = "Latest" |
| 31 | 46 | |
| @@ -26,6 +26,21 @@ other = "Subscribe via RSS" | |||
| 26 | [rssLabel] | 26 | [rssLabel] |
| 27 | other = "RSS" | 27 | other = "RSS" |
| 28 | 28 | ||
| 29 | +[feedPageTitle] | ||
| 30 | +other = "RSS feed" | ||
| 31 | + | ||
| 32 | +[feedIntro] | ||
| 33 | +other = "You are looking at the RSS feed for {{ .Site }} — the whole archive, in a format made for software rather than for reading here. Hand the address below to a feed reader and every new post turns up on its own, with no algorithm in between." | ||
| 34 | + | ||
| 35 | +[feedURL] | ||
| 36 | +other = "Feed URL" | ||
| 37 | + | ||
| 38 | +[feedEmpty] | ||
| 39 | +other = "No posts in this feed yet." | ||
| 40 | + | ||
| 41 | +[feedBackHome] | ||
| 42 | +other = "Back to {{ .Site }}" | ||
| 43 | + | ||
| 29 | [latest] | 44 | [latest] |
| 30 | other = "Latest" | 45 | other = "Latest" |
| 31 | 46 | ||
modified
blog/i18n/es.toml +15 -0 | @@ -26,6 +26,21 @@ other = "Suscribirse vía RSS" | ||
| 26 | 26 | [rssLabel] |
| 27 | 27 | other = "RSS" |
| 28 | 28 | |
| 29 | +[feedPageTitle] | |
| 30 | +other = "Canal RSS" | |
| 31 | + | |
| 32 | +[feedIntro] | |
| 33 | +other = "Estás viendo el canal RSS de {{ .Site }} — todo el archivo, en un formato pensado para programas más que para leerlo aquí. Dale la dirección de abajo a un lector de feeds y cada artículo nuevo llegará solo, sin ningún algoritmo de por medio." | |
| 34 | + | |
| 35 | +[feedURL] | |
| 36 | +other = "URL del canal" | |
| 37 | + | |
| 38 | +[feedEmpty] | |
| 39 | +other = "Este canal todavía no tiene artículos." | |
| 40 | + | |
| 41 | +[feedBackHome] | |
| 42 | +other = "Volver a {{ .Site }}" | |
| 43 | + | |
| 29 | 44 | [latest] |
| 30 | 45 | other = "Último" |
| 31 | 46 | |
| @@ -26,6 +26,21 @@ other = "Suscribirse vía RSS" | |||
| 26 | [rssLabel] | 26 | [rssLabel] |
| 27 | other = "RSS" | 27 | other = "RSS" |
| 28 | 28 | ||
| 29 | +[feedPageTitle] | ||
| 30 | +other = "Canal RSS" | ||
| 31 | + | ||
| 32 | +[feedIntro] | ||
| 33 | +other = "Estás viendo el canal RSS de {{ .Site }} — todo el archivo, en un formato pensado para programas más que para leerlo aquí. Dale la dirección de abajo a un lector de feeds y cada artículo nuevo llegará solo, sin ningún algoritmo de por medio." | ||
| 34 | + | ||
| 35 | +[feedURL] | ||
| 36 | +other = "URL del canal" | ||
| 37 | + | ||
| 38 | +[feedEmpty] | ||
| 39 | +other = "Este canal todavía no tiene artículos." | ||
| 40 | + | ||
| 41 | +[feedBackHome] | ||
| 42 | +other = "Volver a {{ .Site }}" | ||
| 43 | + | ||
| 29 | [latest] | 44 | [latest] |
| 30 | other = "Último" | 45 | other = "Último" |
| 31 | 46 | ||
modified
blog/i18n/fr.toml +15 -0 | @@ -26,6 +26,21 @@ other = "S’abonner via RSS" | ||
| 26 | 26 | [rssLabel] |
| 27 | 27 | other = "RSS" |
| 28 | 28 | |
| 29 | +[feedPageTitle] | |
| 30 | +other = "Flux RSS" | |
| 31 | + | |
| 32 | +[feedIntro] | |
| 33 | +other = "Vous consultez le flux RSS de {{ .Site }} — toutes les publications, dans un format fait pour les logiciels plutôt que pour la lecture ici. Donnez l’adresse ci-dessous à un lecteur de flux et chaque nouvel article arrivera tout seul, sans algorithme au milieu." | |
| 34 | + | |
| 35 | +[feedURL] | |
| 36 | +other = "URL du flux" | |
| 37 | + | |
| 38 | +[feedEmpty] | |
| 39 | +other = "Ce flux ne contient encore aucun article." | |
| 40 | + | |
| 41 | +[feedBackHome] | |
| 42 | +other = "Retour à {{ .Site }}" | |
| 43 | + | |
| 29 | 44 | [latest] |
| 30 | 45 | other = "Dernier" |
| 31 | 46 | |
| @@ -26,6 +26,21 @@ other = "S’abonner via RSS" | |||
| 26 | [rssLabel] | 26 | [rssLabel] |
| 27 | other = "RSS" | 27 | other = "RSS" |
| 28 | 28 | ||
| 29 | +[feedPageTitle] | ||
| 30 | +other = "Flux RSS" | ||
| 31 | + | ||
| 32 | +[feedIntro] | ||
| 33 | +other = "Vous consultez le flux RSS de {{ .Site }} — toutes les publications, dans un format fait pour les logiciels plutôt que pour la lecture ici. Donnez l’adresse ci-dessous à un lecteur de flux et chaque nouvel article arrivera tout seul, sans algorithme au milieu." | ||
| 34 | + | ||
| 35 | +[feedURL] | ||
| 36 | +other = "URL du flux" | ||
| 37 | + | ||
| 38 | +[feedEmpty] | ||
| 39 | +other = "Ce flux ne contient encore aucun article." | ||
| 40 | + | ||
| 41 | +[feedBackHome] | ||
| 42 | +other = "Retour à {{ .Site }}" | ||
| 43 | + | ||
| 29 | [latest] | 44 | [latest] |
| 30 | other = "Dernier" | 45 | other = "Dernier" |
| 31 | 46 | ||
modified
blog/i18n/ja.toml +15 -0 | @@ -25,6 +25,21 @@ other = "RSSで購読" | ||
| 25 | 25 | [rssLabel] |
| 26 | 26 | other = "RSS" |
| 27 | 27 | |
| 28 | +[feedPageTitle] | |
| 29 | +other = "RSSフィード" | |
| 30 | + | |
| 31 | +[feedIntro] | |
| 32 | +other = "これは{{ .Site }}のRSSフィードです。すべての記事を、ここで読むためではなくソフトウェアが読むための形式でまとめたものです。下のアドレスをフィードリーダーに登録すれば、新しい記事がアルゴリズムを介さずそのまま届きます。" | |
| 33 | + | |
| 34 | +[feedURL] | |
| 35 | +other = "フィードのURL" | |
| 36 | + | |
| 37 | +[feedEmpty] | |
| 38 | +other = "このフィードにはまだ記事がありません。" | |
| 39 | + | |
| 40 | +[feedBackHome] | |
| 41 | +other = "{{ .Site }}に戻る" | |
| 42 | + | |
| 28 | 43 | [latest] |
| 29 | 44 | other = "最新" |
| 30 | 45 | |
| @@ -25,6 +25,21 @@ other = "RSSで購読" | |||
| 25 | [rssLabel] | 25 | [rssLabel] |
| 26 | other = "RSS" | 26 | other = "RSS" |
| 27 | 27 | ||
| 28 | +[feedPageTitle] | ||
| 29 | +other = "RSSフィード" | ||
| 30 | + | ||
| 31 | +[feedIntro] | ||
| 32 | +other = "これは{{ .Site }}のRSSフィードです。すべての記事を、ここで読むためではなくソフトウェアが読むための形式でまとめたものです。下のアドレスをフィードリーダーに登録すれば、新しい記事がアルゴリズムを介さずそのまま届きます。" | ||
| 33 | + | ||
| 34 | +[feedURL] | ||
| 35 | +other = "フィードのURL" | ||
| 36 | + | ||
| 37 | +[feedEmpty] | ||
| 38 | +other = "このフィードにはまだ記事がありません。" | ||
| 39 | + | ||
| 40 | +[feedBackHome] | ||
| 41 | +other = "{{ .Site }}に戻る" | ||
| 42 | + | ||
| 28 | [latest] | 43 | [latest] |
| 29 | other = "最新" | 44 | other = "最新" |
| 30 | 45 | ||
modified
blog/i18n/pt.toml +15 -0 | @@ -26,6 +26,21 @@ other = "Assine via RSS" | ||
| 26 | 26 | [rssLabel] |
| 27 | 27 | other = "RSS" |
| 28 | 28 | |
| 29 | +[feedPageTitle] | |
| 30 | +other = "Feed RSS" | |
| 31 | + | |
| 32 | +[feedIntro] | |
| 33 | +other = "Você está vendo o feed RSS de {{ .Site }} — todo o arquivo, num formato feito para programas e não para ser lido aqui. Entregue o endereço abaixo a um leitor de feeds e cada post novo chega sozinho, sem nenhum algoritmo no meio." | |
| 34 | + | |
| 35 | +[feedURL] | |
| 36 | +other = "URL do feed" | |
| 37 | + | |
| 38 | +[feedEmpty] | |
| 39 | +other = "Este feed ainda não tem posts." | |
| 40 | + | |
| 41 | +[feedBackHome] | |
| 42 | +other = "Voltar para {{ .Site }}" | |
| 43 | + | |
| 29 | 44 | [latest] |
| 30 | 45 | other = "Mais recente" |
| 31 | 46 | |
| @@ -26,6 +26,21 @@ other = "Assine via RSS" | |||
| 26 | [rssLabel] | 26 | [rssLabel] |
| 27 | other = "RSS" | 27 | other = "RSS" |
| 28 | 28 | ||
| 29 | +[feedPageTitle] | ||
| 30 | +other = "Feed RSS" | ||
| 31 | + | ||
| 32 | +[feedIntro] | ||
| 33 | +other = "Você está vendo o feed RSS de {{ .Site }} — todo o arquivo, num formato feito para programas e não para ser lido aqui. Entregue o endereço abaixo a um leitor de feeds e cada post novo chega sozinho, sem nenhum algoritmo no meio." | ||
| 34 | + | ||
| 35 | +[feedURL] | ||
| 36 | +other = "URL do feed" | ||
| 37 | + | ||
| 38 | +[feedEmpty] | ||
| 39 | +other = "Este feed ainda não tem posts." | ||
| 40 | + | ||
| 41 | +[feedBackHome] | ||
| 42 | +other = "Voltar para {{ .Site }}" | ||
| 43 | + | ||
| 29 | [latest] | 44 | [latest] |
| 30 | other = "Mais recente" | 45 | other = "Mais recente" |
| 31 | 46 | ||
added
blog/layouts/_default/home.feedscript.js +256 -0 | new file mode 100644 | ||
| @@ -0,0 +1,256 @@ | ||
| 1 | +{{- /* | |
| 2 | + Standby for _default/home.feedstyle.xsl, which builds the same page the same | |
| 3 | + way but in XSLT. index.xml embeds this file as an XHTML-namespaced <script> | |
| 4 | + (see _default/rss.xml); a browser running the stylesheet never copies that | |
| 5 | + tag into the transform's output, so this only ever executes where XSLT did | |
| 6 | + not - today an oddity, from Chrome 155 on 2026-11-17 the normal case. | |
| 7 | + | |
| 8 | + Keep it in step with the stylesheet: same elements, same class names, same | |
| 9 | + order. style.scss's "Feed page" section styles whichever of the two ran. | |
| 10 | + | |
| 11 | + Rendered through text/template (isPlainText in hugo.toml), so the strings | |
| 12 | + below are interpolated as one jsonify'd object rather than escaped one at a | |
| 13 | + time - JSON is valid JavaScript, and quoting is the encoder's problem. | |
| 14 | +*/ -}} | |
| 15 | +{{- $feed := "" }} | |
| 16 | +{{- with .OutputFormats.Get "RSS" }}{{ $feed = .Permalink }}{{ end }} | |
| 17 | +{{- $signal := partial "signal.html" $feed }} | |
| 18 | +{{- $css := "" }} | |
| 19 | +{{- $integrity := "" }} | |
| 20 | +{{- with resources.Get "css/style.scss" }} | |
| 21 | + {{- $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" }} | |
| 22 | + {{- with . | toCSS $opts | fingerprint }} | |
| 23 | + {{- $css = .RelPermalink }} | |
| 24 | + {{- $integrity = .Data.Integrity }} | |
| 25 | + {{- end }} | |
| 26 | +{{- end }} | |
| 27 | +{{- $cfg := dict | |
| 28 | + "lang" site.Language.Lang | |
| 29 | + "site" site.Title | |
| 30 | + "home" ("/" | relLangURL) | |
| 31 | + "css" $css | |
| 32 | + "integrity" $integrity | |
| 33 | + "feed" $feed | |
| 34 | + "signalPath" $signal.path | |
| 35 | + "signalHash" $signal.hash | |
| 36 | + "pageTitle" (i18n "feedPageTitle") | |
| 37 | + "intro" (i18n "feedIntro" (dict "Site" site.Title)) | |
| 38 | + "url" (i18n "feedURL") | |
| 39 | + "readMore" (i18n "readMore") | |
| 40 | + "empty" (i18n "feedEmpty") | |
| 41 | + "backHome" (i18n "feedBackHome" (dict "Site" site.Title)) | |
| 42 | + "licenseText" (i18n "licenseText") | |
| 43 | + "licenseURL" (i18n "licenseURL") | |
| 44 | +}} | |
| 45 | +(() => { | |
| 46 | + const C = {{ $cfg | jsonify }}; | |
| 47 | + const SVG = "http://www.w3.org/2000/svg"; | |
| 48 | + const ATOM = "http://www.w3.org/2005/Atom"; | |
| 49 | + const MONTHS = "JanFebMarAprMayJunJulAugSepOctNovDec"; | |
| 50 | + | |
| 51 | + const render = () => { | |
| 52 | + const src = document; | |
| 53 | + // A fresh HTML document to assemble in: its elements land in the XHTML | |
| 54 | + // namespace, which is what lets them render once imported into this XML | |
| 55 | + // document, and building off to the side means a failure half way leaves | |
| 56 | + // the reader with the tag tree rather than half a page. | |
| 57 | + const out = document.implementation.createHTMLDocument(""); | |
| 58 | + const make = (tag, cls, text) => { | |
| 59 | + const el = out.createElement(tag); | |
| 60 | + if (cls) el.className = cls; | |
| 61 | + if (text != null) el.textContent = text; | |
| 62 | + return el; | |
| 63 | + }; | |
| 64 | + const text = (node, sel) => node.querySelector(sel)?.textContent?.trim() ?? ""; | |
| 65 | + | |
| 66 | + out.documentElement.setAttribute("lang", C.lang); | |
| 67 | + | |
| 68 | + const head = out.head; | |
| 69 | + const meta = (attrs) => { | |
| 70 | + const el = out.createElement("meta"); | |
| 71 | + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); | |
| 72 | + head.appendChild(el); | |
| 73 | + }; | |
| 74 | + meta({charset: "utf-8"}); | |
| 75 | + meta({name: "viewport", content: "width=device-width, initial-scale=1"}); | |
| 76 | + meta({name: "color-scheme", content: "light dark"}); | |
| 77 | + meta({name: "theme-color", content: "#f5f2ec", media: "(prefers-color-scheme: light)"}); | |
| 78 | + meta({name: "theme-color", content: "#15171a", media: "(prefers-color-scheme: dark)"}); | |
| 79 | + meta({name: "robots", content: "noindex"}); | |
| 80 | + head.appendChild(make("title", null, `${text(src, "channel > title") || C.site} · ${C.pageTitle}`)); | |
| 81 | + if (C.css) { | |
| 82 | + const link = out.createElement("link"); | |
| 83 | + link.setAttribute("rel", "stylesheet"); | |
| 84 | + link.setAttribute("href", C.css); | |
| 85 | + if (C.integrity) link.setAttribute("integrity", C.integrity); | |
| 86 | + head.appendChild(link); | |
| 87 | + } | |
| 88 | + | |
| 89 | + const wrap = make("div", "wrap"); | |
| 90 | + out.body.appendChild(wrap); | |
| 91 | + | |
| 92 | + // --- Masthead --------------------------------------------------------- | |
| 93 | + const masthead = make("header", "masthead column rise"); | |
| 94 | + const brand = make("p", "masthead__title"); | |
| 95 | + const brandLink = make("a", null, C.site); | |
| 96 | + brandLink.setAttribute("href", C.home); | |
| 97 | + brand.appendChild(brandLink); | |
| 98 | + masthead.appendChild(brand); | |
| 99 | + | |
| 100 | + const signal = make("div", "masthead__signal"); | |
| 101 | + signal.setAttribute("aria-hidden", "true"); | |
| 102 | + signal.setAttribute("title", `sha256 of this page's URL, drawn — ${C.signalHash}`); | |
| 103 | + // createElementNS, not createElement: an <svg> built as an HTML element is | |
| 104 | + // an unknown element that renders nothing. | |
| 105 | + const svg = out.createElementNS(SVG, "svg"); | |
| 106 | + svg.setAttribute("viewBox", "0 0 1000 100"); | |
| 107 | + svg.setAttribute("preserveAspectRatio", "none"); | |
| 108 | + svg.setAttribute("focusable", "false"); | |
| 109 | + const defs = out.createElementNS(SVG, "defs"); | |
| 110 | + const ramp = out.createElementNS(SVG, "linearGradient"); | |
| 111 | + ramp.setAttribute("id", "signal-ramp"); | |
| 112 | + ramp.setAttribute("x1", "0"); | |
| 113 | + ramp.setAttribute("y1", "0"); | |
| 114 | + ramp.setAttribute("x2", "1"); | |
| 115 | + ramp.setAttribute("y2", "0"); | |
| 116 | + for (const [cls, offset] of [["lead", "0"], ["body", "0.24"], ["tail", "1"]]) { | |
| 117 | + const stop = out.createElementNS(SVG, "stop"); | |
| 118 | + stop.setAttribute("class", `signal-ramp__${cls}`); | |
| 119 | + stop.setAttribute("offset", offset); | |
| 120 | + ramp.appendChild(stop); | |
| 121 | + } | |
| 122 | + defs.appendChild(ramp); | |
| 123 | + svg.appendChild(defs); | |
| 124 | + const path = out.createElementNS(SVG, "path"); | |
| 125 | + path.setAttribute("d", C.signalPath); | |
| 126 | + path.setAttribute("pathLength", "100"); | |
| 127 | + svg.appendChild(path); | |
| 128 | + signal.appendChild(svg); | |
| 129 | + masthead.appendChild(signal); | |
| 130 | + wrap.appendChild(masthead); | |
| 131 | + | |
| 132 | + // --- Header and address ---------------------------------------------- | |
| 133 | + const main = out.createElement("main"); | |
| 134 | + main.id = "main"; | |
| 135 | + // Everything the page says lives in one .column, the same measure a post | |
| 136 | + // gets, so the feed page lines up with the rest of the site. | |
| 137 | + const page = make("div", "feed-page column"); | |
| 138 | + main.appendChild(page); | |
| 139 | + | |
| 140 | + const pageHead = make("header", "feed-head rise"); | |
| 141 | + // The mark says "feed" on its own, so it stands in for the kicker that | |
| 142 | + // used to sit above the title and repeat the word. | |
| 143 | + const heading = make("h1", "post-title feed-title"); | |
| 144 | + const icon = out.createElementNS(SVG, "svg"); | |
| 145 | + icon.setAttribute("class", "feed-title__mark"); | |
| 146 | + icon.setAttribute("viewBox", "0 0 24 24"); | |
| 147 | + icon.setAttribute("aria-hidden", "true"); | |
| 148 | + for (const d of [ | |
| 149 | + "M4 11a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 4 13.5V11z", | |
| 150 | + "M4 4a16 16 0 0 1 16 16h-2.5A13.5 13.5 0 0 0 4 6.5V4z", | |
| 151 | + ]) { | |
| 152 | + const p = out.createElementNS(SVG, "path"); | |
| 153 | + p.setAttribute("d", d); | |
| 154 | + icon.appendChild(p); | |
| 155 | + } | |
| 156 | + const dot = out.createElementNS(SVG, "circle"); | |
| 157 | + dot.setAttribute("cx", "5.5"); | |
| 158 | + dot.setAttribute("cy", "18.5"); | |
| 159 | + dot.setAttribute("r", "2"); | |
| 160 | + icon.appendChild(dot); | |
| 161 | + heading.append(icon, C.pageTitle); | |
| 162 | + pageHead.append(heading, make("hr", "post-rule")); | |
| 163 | + page.appendChild(pageHead); | |
| 164 | + | |
| 165 | + const intro = make("div", "feed-intro rise"); | |
| 166 | + intro.setAttribute("style", "--i: 1"); | |
| 167 | + const address = make("div", "feed-address"); | |
| 168 | + // 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 | |
| 170 | + // selector cannot name the Atom namespace. | |
| 171 | + const self = [...src.getElementsByTagNameNS(ATOM, "link")] | |
| 172 | + .find((l) => l.getAttribute("rel") === "self")?.getAttribute("href"); | |
| 173 | + address.append( | |
| 174 | + make("span", "feed-address__label", C.url), | |
| 175 | + make("code", "feed-address__url", self || C.feed), | |
| 176 | + ); | |
| 177 | + intro.append(make("p", "feed-intro__text", C.intro), address); | |
| 178 | + page.appendChild(intro); | |
| 179 | + | |
| 180 | + // --- Cards ------------------------------------------------------------ | |
| 181 | + const items = [...src.querySelectorAll("channel > item")]; | |
| 182 | + if (items.length) { | |
| 183 | + const grid = make("div", "feed-grid"); | |
| 184 | + items.forEach((item, i) => { | |
| 185 | + const card = make("article", "feed-card rise"); | |
| 186 | + card.setAttribute("style", `--i: ${i + 2}`); | |
| 187 | + | |
| 188 | + // Read off the fixed offsets of RFC 822 rather than through Date, so | |
| 189 | + // the day shown is the one the feed states and not whatever it becomes | |
| 190 | + // in the reader's timezone - and so this agrees with the XSLT, which | |
| 191 | + // has only string operations to work with. | |
| 192 | + const pub = text(item, "pubDate"); | |
| 193 | + const day = pub.slice(5, 7), mon = pub.slice(8, 11), year = pub.slice(12, 16); | |
| 194 | + const when = make("time", "feed-card__date", `${mon} ${day}, ${year}`); | |
| 195 | + if (MONTHS.includes(mon)) { | |
| 196 | + const n = String(MONTHS.indexOf(mon) / 3 + 1).padStart(2, "0"); | |
| 197 | + when.setAttribute("datetime", `${year}-${n}-${day}`); | |
| 198 | + } | |
| 199 | + card.appendChild(when); | |
| 200 | + | |
| 201 | + const title = make("h2", "feed-card__title"); | |
| 202 | + const link = make("a", null, text(item, "title")); | |
| 203 | + link.setAttribute("href", text(item, "link")); | |
| 204 | + title.appendChild(link); | |
| 205 | + card.appendChild(title); | |
| 206 | + | |
| 207 | + // <description> holds the summary's markup, escaped by the feed; | |
| 208 | + // innerHTML is how it gets unescaped, matching the stylesheet's | |
| 209 | + // disable-output-escaping. Both put this site's own published HTML | |
| 210 | + // back on this site's own page - the same bytes the post already | |
| 211 | + // serves - so there is no new trust boundary here. | |
| 212 | + const summary = item.querySelector("description")?.textContent?.trim(); | |
| 213 | + if (summary) { | |
| 214 | + const excerpt = make("div", "feed-card__excerpt"); | |
| 215 | + excerpt.innerHTML = summary; | |
| 216 | + card.appendChild(excerpt); | |
| 217 | + } | |
| 218 | + | |
| 219 | + const more = make("span", "feed-card__more", C.readMore); | |
| 220 | + more.setAttribute("aria-hidden", "true"); | |
| 221 | + card.appendChild(more); | |
| 222 | + grid.appendChild(card); | |
| 223 | + }); | |
| 224 | + page.appendChild(grid); | |
| 225 | + } else { | |
| 226 | + page.appendChild(make("p", "feed-empty", C.empty)); | |
| 227 | + } | |
| 228 | + wrap.appendChild(main); | |
| 229 | + | |
| 230 | + // --- Footer ----------------------------------------------------------- | |
| 231 | + const foot = make("footer", "site-foot column"); | |
| 232 | + const back = make("a", null, C.backHome); | |
| 233 | + back.setAttribute("href", C.home); | |
| 234 | + const backWrap = make("span"); | |
| 235 | + backWrap.appendChild(back); | |
| 236 | + const sep = make("span", "sep", "·"); | |
| 237 | + sep.setAttribute("aria-hidden", "true"); | |
| 238 | + const license = make("span", null, `${C.licenseText} `); | |
| 239 | + const licenseLink = make("a", null, "CC BY-SA"); | |
| 240 | + licenseLink.setAttribute("href", C.licenseURL); | |
| 241 | + license.appendChild(licenseLink); | |
| 242 | + foot.append(backWrap, sep, license); | |
| 243 | + wrap.appendChild(foot); | |
| 244 | + | |
| 245 | + src.replaceChild(src.importNode(out.documentElement, true), src.documentElement); | |
| 246 | + }; | |
| 247 | + | |
| 248 | + // A script in an XML document runs the moment the parser reaches it, which | |
| 249 | + // is before any <item> has been parsed - rendering here would produce a page | |
| 250 | + // with a header and nothing under it. Wait for the parse to finish. | |
| 251 | + if (document.readyState === "loading") { | |
| 252 | + document.addEventListener("DOMContentLoaded", render, {once: true}); | |
| 253 | + } else { | |
| 254 | + render(); | |
| 255 | + } | |
| 256 | +})(); | |
| new file mode 100644 | |||
| @@ -0,0 +1,256 @@ | |||
| 1 | +{{- /* | ||
| 2 | + Standby for _default/home.feedstyle.xsl, which builds the same page the same | ||
| 3 | + way but in XSLT. index.xml embeds this file as an XHTML-namespaced <script> | ||
| 4 | + (see _default/rss.xml); a browser running the stylesheet never copies that | ||
| 5 | + tag into the transform's output, so this only ever executes where XSLT did | ||
| 6 | + not - today an oddity, from Chrome 155 on 2026-11-17 the normal case. | ||
| 7 | + | ||
| 8 | + Keep it in step with the stylesheet: same elements, same class names, same | ||
| 9 | + order. style.scss's "Feed page" section styles whichever of the two ran. | ||
| 10 | + | ||
| 11 | + Rendered through text/template (isPlainText in hugo.toml), so the strings | ||
| 12 | + below are interpolated as one jsonify'd object rather than escaped one at a | ||
| 13 | + time - JSON is valid JavaScript, and quoting is the encoder's problem. | ||
| 14 | +*/ -}} | ||
| 15 | +{{- $feed := "" }} | ||
| 16 | +{{- with .OutputFormats.Get "RSS" }}{{ $feed = .Permalink }}{{ end }} | ||
| 17 | +{{- $signal := partial "signal.html" $feed }} | ||
| 18 | +{{- $css := "" }} | ||
| 19 | +{{- $integrity := "" }} | ||
| 20 | +{{- with resources.Get "css/style.scss" }} | ||
| 21 | + {{- $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" }} | ||
| 22 | + {{- with . | toCSS $opts | fingerprint }} | ||
| 23 | + {{- $css = .RelPermalink }} | ||
| 24 | + {{- $integrity = .Data.Integrity }} | ||
| 25 | + {{- end }} | ||
| 26 | +{{- end }} | ||
| 27 | +{{- $cfg := dict | ||
| 28 | + "lang" site.Language.Lang | ||
| 29 | + "site" site.Title | ||
| 30 | + "home" ("/" | relLangURL) | ||
| 31 | + "css" $css | ||
| 32 | + "integrity" $integrity | ||
| 33 | + "feed" $feed | ||
| 34 | + "signalPath" $signal.path | ||
| 35 | + "signalHash" $signal.hash | ||
| 36 | + "pageTitle" (i18n "feedPageTitle") | ||
| 37 | + "intro" (i18n "feedIntro" (dict "Site" site.Title)) | ||
| 38 | + "url" (i18n "feedURL") | ||
| 39 | + "readMore" (i18n "readMore") | ||
| 40 | + "empty" (i18n "feedEmpty") | ||
| 41 | + "backHome" (i18n "feedBackHome" (dict "Site" site.Title)) | ||
| 42 | + "licenseText" (i18n "licenseText") | ||
| 43 | + "licenseURL" (i18n "licenseURL") | ||
| 44 | +}} | ||
| 45 | +(() => { | ||
| 46 | + const C = {{ $cfg | jsonify }}; | ||
| 47 | + const SVG = "http://www.w3.org/2000/svg"; | ||
| 48 | + const ATOM = "http://www.w3.org/2005/Atom"; | ||
| 49 | + const MONTHS = "JanFebMarAprMayJunJulAugSepOctNovDec"; | ||
| 50 | + | ||
| 51 | + const render = () => { | ||
| 52 | + const src = document; | ||
| 53 | + // A fresh HTML document to assemble in: its elements land in the XHTML | ||
| 54 | + // namespace, which is what lets them render once imported into this XML | ||
| 55 | + // document, and building off to the side means a failure half way leaves | ||
| 56 | + // the reader with the tag tree rather than half a page. | ||
| 57 | + const out = document.implementation.createHTMLDocument(""); | ||
| 58 | + const make = (tag, cls, text) => { | ||
| 59 | + const el = out.createElement(tag); | ||
| 60 | + if (cls) el.className = cls; | ||
| 61 | + if (text != null) el.textContent = text; | ||
| 62 | + return el; | ||
| 63 | + }; | ||
| 64 | + const text = (node, sel) => node.querySelector(sel)?.textContent?.trim() ?? ""; | ||
| 65 | + | ||
| 66 | + out.documentElement.setAttribute("lang", C.lang); | ||
| 67 | + | ||
| 68 | + const head = out.head; | ||
| 69 | + const meta = (attrs) => { | ||
| 70 | + const el = out.createElement("meta"); | ||
| 71 | + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); | ||
| 72 | + head.appendChild(el); | ||
| 73 | + }; | ||
| 74 | + meta({charset: "utf-8"}); | ||
| 75 | + meta({name: "viewport", content: "width=device-width, initial-scale=1"}); | ||
| 76 | + meta({name: "color-scheme", content: "light dark"}); | ||
| 77 | + meta({name: "theme-color", content: "#f5f2ec", media: "(prefers-color-scheme: light)"}); | ||
| 78 | + meta({name: "theme-color", content: "#15171a", media: "(prefers-color-scheme: dark)"}); | ||
| 79 | + meta({name: "robots", content: "noindex"}); | ||
| 80 | + head.appendChild(make("title", null, `${text(src, "channel > title") || C.site} · ${C.pageTitle}`)); | ||
| 81 | + if (C.css) { | ||
| 82 | + const link = out.createElement("link"); | ||
| 83 | + link.setAttribute("rel", "stylesheet"); | ||
| 84 | + link.setAttribute("href", C.css); | ||
| 85 | + if (C.integrity) link.setAttribute("integrity", C.integrity); | ||
| 86 | + head.appendChild(link); | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + const wrap = make("div", "wrap"); | ||
| 90 | + out.body.appendChild(wrap); | ||
| 91 | + | ||
| 92 | + // --- Masthead --------------------------------------------------------- | ||
| 93 | + const masthead = make("header", "masthead column rise"); | ||
| 94 | + const brand = make("p", "masthead__title"); | ||
| 95 | + const brandLink = make("a", null, C.site); | ||
| 96 | + brandLink.setAttribute("href", C.home); | ||
| 97 | + brand.appendChild(brandLink); | ||
| 98 | + masthead.appendChild(brand); | ||
| 99 | + | ||
| 100 | + const signal = make("div", "masthead__signal"); | ||
| 101 | + signal.setAttribute("aria-hidden", "true"); | ||
| 102 | + signal.setAttribute("title", `sha256 of this page's URL, drawn — ${C.signalHash}`); | ||
| 103 | + // createElementNS, not createElement: an <svg> built as an HTML element is | ||
| 104 | + // an unknown element that renders nothing. | ||
| 105 | + const svg = out.createElementNS(SVG, "svg"); | ||
| 106 | + svg.setAttribute("viewBox", "0 0 1000 100"); | ||
| 107 | + svg.setAttribute("preserveAspectRatio", "none"); | ||
| 108 | + svg.setAttribute("focusable", "false"); | ||
| 109 | + const defs = out.createElementNS(SVG, "defs"); | ||
| 110 | + const ramp = out.createElementNS(SVG, "linearGradient"); | ||
| 111 | + ramp.setAttribute("id", "signal-ramp"); | ||
| 112 | + ramp.setAttribute("x1", "0"); | ||
| 113 | + ramp.setAttribute("y1", "0"); | ||
| 114 | + ramp.setAttribute("x2", "1"); | ||
| 115 | + ramp.setAttribute("y2", "0"); | ||
| 116 | + for (const [cls, offset] of [["lead", "0"], ["body", "0.24"], ["tail", "1"]]) { | ||
| 117 | + const stop = out.createElementNS(SVG, "stop"); | ||
| 118 | + stop.setAttribute("class", `signal-ramp__${cls}`); | ||
| 119 | + stop.setAttribute("offset", offset); | ||
| 120 | + ramp.appendChild(stop); | ||
| 121 | + } | ||
| 122 | + defs.appendChild(ramp); | ||
| 123 | + svg.appendChild(defs); | ||
| 124 | + const path = out.createElementNS(SVG, "path"); | ||
| 125 | + path.setAttribute("d", C.signalPath); | ||
| 126 | + path.setAttribute("pathLength", "100"); | ||
| 127 | + svg.appendChild(path); | ||
| 128 | + signal.appendChild(svg); | ||
| 129 | + masthead.appendChild(signal); | ||
| 130 | + wrap.appendChild(masthead); | ||
| 131 | + | ||
| 132 | + // --- Header and address ---------------------------------------------- | ||
| 133 | + const main = out.createElement("main"); | ||
| 134 | + main.id = "main"; | ||
| 135 | + // Everything the page says lives in one .column, the same measure a post | ||
| 136 | + // gets, so the feed page lines up with the rest of the site. | ||
| 137 | + const page = make("div", "feed-page column"); | ||
| 138 | + main.appendChild(page); | ||
| 139 | + | ||
| 140 | + const pageHead = make("header", "feed-head rise"); | ||
| 141 | + // The mark says "feed" on its own, so it stands in for the kicker that | ||
| 142 | + // used to sit above the title and repeat the word. | ||
| 143 | + const heading = make("h1", "post-title feed-title"); | ||
| 144 | + const icon = out.createElementNS(SVG, "svg"); | ||
| 145 | + icon.setAttribute("class", "feed-title__mark"); | ||
| 146 | + icon.setAttribute("viewBox", "0 0 24 24"); | ||
| 147 | + icon.setAttribute("aria-hidden", "true"); | ||
| 148 | + for (const d of [ | ||
| 149 | + "M4 11a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 4 13.5V11z", | ||
| 150 | + "M4 4a16 16 0 0 1 16 16h-2.5A13.5 13.5 0 0 0 4 6.5V4z", | ||
| 151 | + ]) { | ||
| 152 | + const p = out.createElementNS(SVG, "path"); | ||
| 153 | + p.setAttribute("d", d); | ||
| 154 | + icon.appendChild(p); | ||
| 155 | + } | ||
| 156 | + const dot = out.createElementNS(SVG, "circle"); | ||
| 157 | + dot.setAttribute("cx", "5.5"); | ||
| 158 | + dot.setAttribute("cy", "18.5"); | ||
| 159 | + dot.setAttribute("r", "2"); | ||
| 160 | + icon.appendChild(dot); | ||
| 161 | + heading.append(icon, C.pageTitle); | ||
| 162 | + pageHead.append(heading, make("hr", "post-rule")); | ||
| 163 | + page.appendChild(pageHead); | ||
| 164 | + | ||
| 165 | + const intro = make("div", "feed-intro rise"); | ||
| 166 | + intro.setAttribute("style", "--i: 1"); | ||
| 167 | + const address = make("div", "feed-address"); | ||
| 168 | + // 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 | ||
| 170 | + // selector cannot name the Atom namespace. | ||
| 171 | + const self = [...src.getElementsByTagNameNS(ATOM, "link")] | ||
| 172 | + .find((l) => l.getAttribute("rel") === "self")?.getAttribute("href"); | ||
| 173 | + address.append( | ||
| 174 | + make("span", "feed-address__label", C.url), | ||
| 175 | + make("code", "feed-address__url", self || C.feed), | ||
| 176 | + ); | ||
| 177 | + intro.append(make("p", "feed-intro__text", C.intro), address); | ||
| 178 | + page.appendChild(intro); | ||
| 179 | + | ||
| 180 | + // --- Cards ------------------------------------------------------------ | ||
| 181 | + const items = [...src.querySelectorAll("channel > item")]; | ||
| 182 | + if (items.length) { | ||
| 183 | + const grid = make("div", "feed-grid"); | ||
| 184 | + items.forEach((item, i) => { | ||
| 185 | + const card = make("article", "feed-card rise"); | ||
| 186 | + card.setAttribute("style", `--i: ${i + 2}`); | ||
| 187 | + | ||
| 188 | + // Read off the fixed offsets of RFC 822 rather than through Date, so | ||
| 189 | + // the day shown is the one the feed states and not whatever it becomes | ||
| 190 | + // in the reader's timezone - and so this agrees with the XSLT, which | ||
| 191 | + // has only string operations to work with. | ||
| 192 | + const pub = text(item, "pubDate"); | ||
| 193 | + const day = pub.slice(5, 7), mon = pub.slice(8, 11), year = pub.slice(12, 16); | ||
| 194 | + const when = make("time", "feed-card__date", `${mon} ${day}, ${year}`); | ||
| 195 | + if (MONTHS.includes(mon)) { | ||
| 196 | + const n = String(MONTHS.indexOf(mon) / 3 + 1).padStart(2, "0"); | ||
| 197 | + when.setAttribute("datetime", `${year}-${n}-${day}`); | ||
| 198 | + } | ||
| 199 | + card.appendChild(when); | ||
| 200 | + | ||
| 201 | + const title = make("h2", "feed-card__title"); | ||
| 202 | + const link = make("a", null, text(item, "title")); | ||
| 203 | + link.setAttribute("href", text(item, "link")); | ||
| 204 | + title.appendChild(link); | ||
| 205 | + card.appendChild(title); | ||
| 206 | + | ||
| 207 | + // <description> holds the summary's markup, escaped by the feed; | ||
| 208 | + // innerHTML is how it gets unescaped, matching the stylesheet's | ||
| 209 | + // disable-output-escaping. Both put this site's own published HTML | ||
| 210 | + // back on this site's own page - the same bytes the post already | ||
| 211 | + // serves - so there is no new trust boundary here. | ||
| 212 | + const summary = item.querySelector("description")?.textContent?.trim(); | ||
| 213 | + if (summary) { | ||
| 214 | + const excerpt = make("div", "feed-card__excerpt"); | ||
| 215 | + excerpt.innerHTML = summary; | ||
| 216 | + card.appendChild(excerpt); | ||
| 217 | + } | ||
| 218 | + | ||
| 219 | + const more = make("span", "feed-card__more", C.readMore); | ||
| 220 | + more.setAttribute("aria-hidden", "true"); | ||
| 221 | + card.appendChild(more); | ||
| 222 | + grid.appendChild(card); | ||
| 223 | + }); | ||
| 224 | + page.appendChild(grid); | ||
| 225 | + } else { | ||
| 226 | + page.appendChild(make("p", "feed-empty", C.empty)); | ||
| 227 | + } | ||
| 228 | + wrap.appendChild(main); | ||
| 229 | + | ||
| 230 | + // --- Footer ----------------------------------------------------------- | ||
| 231 | + const foot = make("footer", "site-foot column"); | ||
| 232 | + const back = make("a", null, C.backHome); | ||
| 233 | + back.setAttribute("href", C.home); | ||
| 234 | + const backWrap = make("span"); | ||
| 235 | + backWrap.appendChild(back); | ||
| 236 | + const sep = make("span", "sep", "·"); | ||
| 237 | + sep.setAttribute("aria-hidden", "true"); | ||
| 238 | + const license = make("span", null, `${C.licenseText} `); | ||
| 239 | + const licenseLink = make("a", null, "CC BY-SA"); | ||
| 240 | + licenseLink.setAttribute("href", C.licenseURL); | ||
| 241 | + license.appendChild(licenseLink); | ||
| 242 | + foot.append(backWrap, sep, license); | ||
| 243 | + wrap.appendChild(foot); | ||
| 244 | + | ||
| 245 | + src.replaceChild(src.importNode(out.documentElement, true), src.documentElement); | ||
| 246 | + }; | ||
| 247 | + | ||
| 248 | + // A script in an XML document runs the moment the parser reaches it, which | ||
| 249 | + // is before any <item> has been parsed - rendering here would produce a page | ||
| 250 | + // with a header and nothing under it. Wait for the parse to finish. | ||
| 251 | + if (document.readyState === "loading") { | ||
| 252 | + document.addEventListener("DOMContentLoaded", render, {once: true}); | ||
| 253 | + } else { | ||
| 254 | + render(); | ||
| 255 | + } | ||
| 256 | +})(); | ||
added
blog/layouts/_default/home.feedstyle.xsl +180 -0 | new file mode 100644 | ||
| @@ -0,0 +1,180 @@ | ||
| 1 | +{{- /* | |
| 2 | + The feed, rendered for a person instead of a program. | |
| 3 | + | |
| 4 | + A browser that meets <?xml-stylesheet type="text/xsl"?> in index.xml (see | |
| 5 | + _default/rss.xml) fetches this and runs the transform itself, so the markup | |
| 6 | + below is produced client-side from the RSS - there is no HTML page on disk | |
| 7 | + for it. home.feedscript.js builds the same markup with DOM calls for | |
| 8 | + browsers that no longer run XSLT; the two are meant to be read side by side, | |
| 9 | + and the class names they emit are styled under "Feed page" in style.scss. | |
| 10 | + | |
| 11 | + Hugo's part is the frame: the compiled stylesheet's hashed URL, the | |
| 12 | + translated strings, this language's feed address, the signal curve. XSLT 1.0 | |
| 13 | + is all a browser implements, so everything below stays inside it - no | |
| 14 | + xsl:function, no EXSLT, no XPath 2. | |
| 15 | + | |
| 16 | + Rendered through text/template (isPlainText in hugo.toml), which does no | |
| 17 | + escaping of its own: anything interpolated into the XML is escaped here, by | |
| 18 | + hand, with transform.XMLEscape. | |
| 19 | +*/ -}} | |
| 20 | +{{- $feed := "" }} | |
| 21 | +{{- with .OutputFormats.Get "RSS" }}{{ $feed = .Permalink }}{{ end }} | |
| 22 | +{{- $signal := partial "signal.html" $feed }} | |
| 23 | +{{- $css := "" }} | |
| 24 | +{{- $integrity := "" }} | |
| 25 | +{{- with resources.Get "css/style.scss" }} | |
| 26 | + {{- $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" }} | |
| 27 | + {{- with . | toCSS $opts | fingerprint }} | |
| 28 | + {{- $css = .RelPermalink }} | |
| 29 | + {{- $integrity = .Data.Integrity }} | |
| 30 | + {{- end }} | |
| 31 | +{{- end -}} | |
| 32 | +{{- /* -}} 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 | |
| 34 | + enough to make a browser refuse the whole stylesheet. */ -}} | |
| 35 | +<?xml version="1.0" encoding="utf-8"?> | |
| 36 | +<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> | |
| 37 | + {{- /* | |
| 38 | + doctype-system is not decoration: with method="html" and no doctype at all | |
| 39 | + the browser renders the result in quirks mode, where the layout collapses. | |
| 40 | + "about:legacy-compat" is the HTML5 spec's own escape hatch for generators | |
| 41 | + that cannot emit a bare <!DOCTYPE html>. | |
| 42 | + */}} | |
| 43 | + <xsl:output method="html" encoding="utf-8" indent="yes" doctype-system="about:legacy-compat"/> | |
| 44 | + | |
| 45 | + <xsl:template match="/"> | |
| 46 | + <html lang="{{ transform.XMLEscape site.Language.Lang }}"> | |
| 47 | + <head> | |
| 48 | + <meta charset="utf-8"/> | |
| 49 | + <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| 50 | + <meta name="color-scheme" content="light dark"/> | |
| 51 | + <meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)"/> | |
| 52 | + <meta name="theme-color" content="#15171a" media="(prefers-color-scheme: dark)"/> | |
| 53 | + {{- /* Not a page anyone should reach from a search result; the feed | |
| 54 | + URL itself is the thing worth indexing, and only as a feed. */}} | |
| 55 | + <meta name="robots" content="noindex"/> | |
| 56 | + <title><xsl:value-of select="/rss/channel/title"/> · {{ transform.XMLEscape (i18n "feedPageTitle") }}</title> | |
| 57 | + {{- with $css }} | |
| 58 | + <link rel="stylesheet" type="text/css" href="{{ transform.XMLEscape . }}"{{ with $integrity }} integrity="{{ transform.XMLEscape . }}"{{ end }}/> | |
| 59 | + {{- end }} | |
| 60 | + </head> | |
| 61 | + <body> | |
| 62 | + <div class="wrap"> | |
| 63 | + <header class="masthead column rise"> | |
| 64 | + <p class="masthead__title"><a href="{{ transform.XMLEscape ("/" | relLangURL) }}">{{ transform.XMLEscape site.Title }}</a></p> | |
| 65 | + {{- /* The same sha256-of-this-URL curve every page draws, for the | |
| 66 | + feed's own address - so this page is signed like the rest. */}} | |
| 67 | + <div class="masthead__signal" aria-hidden="true" title="sha256 of this page's URL, drawn — {{ transform.XMLEscape $signal.hash }}"> | |
| 68 | + <svg viewBox="0 0 1000 100" preserveAspectRatio="none" focusable="false"> | |
| 69 | + <defs> | |
| 70 | + <linearGradient id="signal-ramp" x1="0" y1="0" x2="1" y2="0"> | |
| 71 | + <stop class="signal-ramp__lead" offset="0"/> | |
| 72 | + <stop class="signal-ramp__body" offset="0.24"/> | |
| 73 | + <stop class="signal-ramp__tail" offset="1"/> | |
| 74 | + </linearGradient> | |
| 75 | + </defs> | |
| 76 | + <path d="{{ transform.XMLEscape $signal.path }}" pathLength="100"/> | |
| 77 | + </svg> | |
| 78 | + </div> | |
| 79 | + </header> | |
| 80 | + | |
| 81 | + <main id="main"> | |
| 82 | + <div class="feed-page column"> | |
| 83 | + <header class="feed-head rise"> | |
| 84 | + {{- /* The mark says "feed" on its own, so it stands in for the | |
| 85 | + kicker that used to sit above the title and repeat the word. */}} | |
| 86 | + <h1 class="post-title feed-title"> | |
| 87 | + <svg class="feed-title__mark" viewBox="0 0 24 24" aria-hidden="true"> | |
| 88 | + <path d="M4 11a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 4 13.5V11z"/> | |
| 89 | + <path d="M4 4a16 16 0 0 1 16 16h-2.5A13.5 13.5 0 0 0 4 6.5V4z"/> | |
| 90 | + <circle cx="5.5" cy="18.5" r="2"/> | |
| 91 | + </svg> | |
| 92 | + <xsl:text>{{ transform.XMLEscape (i18n "feedPageTitle") }}</xsl:text> | |
| 93 | + </h1> | |
| 94 | + <hr class="post-rule"/> | |
| 95 | + </header> | |
| 96 | + | |
| 97 | + <div class="feed-intro rise" style="--i: 1"> | |
| 98 | + <p class="feed-intro__text">{{ transform.XMLEscape (i18n "feedIntro" (dict "Site" site.Title)) }}</p> | |
| 99 | + <div class="feed-address"> | |
| 100 | + <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> | |
| 112 | + </div> | |
| 113 | + </div> | |
| 114 | + | |
| 115 | + <xsl:choose> | |
| 116 | + <xsl:when test="/rss/channel/item"> | |
| 117 | + <div class="feed-grid"> | |
| 118 | + <xsl:for-each select="/rss/channel/item"> | |
| 119 | + <article class="feed-card rise" style="--i: {position() + 1}"> | |
| 120 | + <time class="feed-card__date"> | |
| 121 | + <xsl:attribute name="datetime"> | |
| 122 | + <xsl:call-template name="iso-date"> | |
| 123 | + <xsl:with-param name="rfc" select="pubDate"/> | |
| 124 | + </xsl:call-template> | |
| 125 | + </xsl:attribute> | |
| 126 | + <xsl:value-of select="concat(substring(pubDate, 9, 3), ' ', substring(pubDate, 6, 2), ', ', substring(pubDate, 13, 4))"/> | |
| 127 | + </time> | |
| 128 | + <h2 class="feed-card__title"> | |
| 129 | + <a href="{link}"><xsl:value-of select="title"/></a> | |
| 130 | + </h2> | |
| 131 | + {{- /* <description> carries the summary's real markup, | |
| 132 | + escaped by the feed. disable-output-escaping puts it | |
| 133 | + back, which is why the container is a <div> - a summary | |
| 134 | + is free to be more than one <p>, and <p><p></p></p> is | |
| 135 | + not. */}} | |
| 136 | + <xsl:if test="normalize-space(description) != ''"> | |
| 137 | + <div class="feed-card__excerpt"> | |
| 138 | + <xsl:value-of select="description" disable-output-escaping="yes"/> | |
| 139 | + </div> | |
| 140 | + </xsl:if> | |
| 141 | + <span class="feed-card__more" aria-hidden="true">{{ transform.XMLEscape (i18n "readMore") }}</span> | |
| 142 | + </article> | |
| 143 | + </xsl:for-each> | |
| 144 | + </div> | |
| 145 | + </xsl:when> | |
| 146 | + <xsl:otherwise> | |
| 147 | + <p class="feed-empty">{{ transform.XMLEscape (i18n "feedEmpty") }}</p> | |
| 148 | + </xsl:otherwise> | |
| 149 | + </xsl:choose> | |
| 150 | + </div> | |
| 151 | + </main> | |
| 152 | + | |
| 153 | + <footer class="site-foot column"> | |
| 154 | + <span><a href="{{ transform.XMLEscape ("/" | relLangURL) }}">{{ transform.XMLEscape (i18n "feedBackHome" (dict "Site" site.Title)) }}</a></span> | |
| 155 | + <span class="sep" aria-hidden="true">·</span> | |
| 156 | + <span>{{ transform.XMLEscape (i18n "licenseText") }} <a href="{{ transform.XMLEscape (i18n "licenseURL") }}">CC BY-SA</a></span> | |
| 157 | + </footer> | |
| 158 | + </div> | |
| 159 | + </body> | |
| 160 | + </html> | |
| 161 | + </xsl:template> | |
| 162 | + | |
| 163 | + {{- /* | |
| 164 | + RFC 822 ("Fri, 13 Apr 1990 09:00:00 +0100") to ISO 8601 ("1990-04-13"), | |
| 165 | + for <time datetime>. Hugo always zero-pads the day, so every field is at a | |
| 166 | + fixed offset; the month is the only one needing work, and its number is | |
| 167 | + just where its abbreviation starts in the list below, divided by three. | |
| 168 | + */}} | |
| 169 | + <xsl:template name="iso-date"> | |
| 170 | + <xsl:param name="rfc"/> | |
| 171 | + <xsl:variable name="months" select="'JanFebMarAprMayJunJulAugSepOctNovDec'"/> | |
| 172 | + <xsl:variable name="month" select="substring($rfc, 9, 3)"/> | |
| 173 | + <xsl:if test="contains($months, $month)"> | |
| 174 | + <xsl:value-of select="concat( | |
| 175 | + substring($rfc, 13, 4), '-', | |
| 176 | + format-number((string-length(substring-before($months, $month)) div 3) + 1, '00'), '-', | |
| 177 | + substring($rfc, 6, 2))"/> | |
| 178 | + </xsl:if> | |
| 179 | + </xsl:template> | |
| 180 | +</xsl:stylesheet> | |
| new file mode 100644 | |||
| @@ -0,0 +1,180 @@ | |||
| 1 | +{{- /* | ||
| 2 | + The feed, rendered for a person instead of a program. | ||
| 3 | + | ||
| 4 | + A browser that meets <?xml-stylesheet type="text/xsl"?> in index.xml (see | ||
| 5 | + _default/rss.xml) fetches this and runs the transform itself, so the markup | ||
| 6 | + below is produced client-side from the RSS - there is no HTML page on disk | ||
| 7 | + for it. home.feedscript.js builds the same markup with DOM calls for | ||
| 8 | + browsers that no longer run XSLT; the two are meant to be read side by side, | ||
| 9 | + and the class names they emit are styled under "Feed page" in style.scss. | ||
| 10 | + | ||
| 11 | + Hugo's part is the frame: the compiled stylesheet's hashed URL, the | ||
| 12 | + translated strings, this language's feed address, the signal curve. XSLT 1.0 | ||
| 13 | + is all a browser implements, so everything below stays inside it - no | ||
| 14 | + xsl:function, no EXSLT, no XPath 2. | ||
| 15 | + | ||
| 16 | + Rendered through text/template (isPlainText in hugo.toml), which does no | ||
| 17 | + escaping of its own: anything interpolated into the XML is escaped here, by | ||
| 18 | + hand, with transform.XMLEscape. | ||
| 19 | +*/ -}} | ||
| 20 | +{{- $feed := "" }} | ||
| 21 | +{{- with .OutputFormats.Get "RSS" }}{{ $feed = .Permalink }}{{ end }} | ||
| 22 | +{{- $signal := partial "signal.html" $feed }} | ||
| 23 | +{{- $css := "" }} | ||
| 24 | +{{- $integrity := "" }} | ||
| 25 | +{{- with resources.Get "css/style.scss" }} | ||
| 26 | + {{- $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" }} | ||
| 27 | + {{- with . | toCSS $opts | fingerprint }} | ||
| 28 | + {{- $css = .RelPermalink }} | ||
| 29 | + {{- $integrity = .Data.Integrity }} | ||
| 30 | + {{- end }} | ||
| 31 | +{{- end -}} | ||
| 32 | +{{- /* -}} 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 | ||
| 34 | + enough to make a browser refuse the whole stylesheet. */ -}} | ||
| 35 | +<?xml version="1.0" encoding="utf-8"?> | ||
| 36 | +<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> | ||
| 37 | + {{- /* | ||
| 38 | + doctype-system is not decoration: with method="html" and no doctype at all | ||
| 39 | + the browser renders the result in quirks mode, where the layout collapses. | ||
| 40 | + "about:legacy-compat" is the HTML5 spec's own escape hatch for generators | ||
| 41 | + that cannot emit a bare <!DOCTYPE html>. | ||
| 42 | + */}} | ||
| 43 | + <xsl:output method="html" encoding="utf-8" indent="yes" doctype-system="about:legacy-compat"/> | ||
| 44 | + | ||
| 45 | + <xsl:template match="/"> | ||
| 46 | + <html lang="{{ transform.XMLEscape site.Language.Lang }}"> | ||
| 47 | + <head> | ||
| 48 | + <meta charset="utf-8"/> | ||
| 49 | + <meta name="viewport" content="width=device-width, initial-scale=1"/> | ||
| 50 | + <meta name="color-scheme" content="light dark"/> | ||
| 51 | + <meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)"/> | ||
| 52 | + <meta name="theme-color" content="#15171a" media="(prefers-color-scheme: dark)"/> | ||
| 53 | + {{- /* Not a page anyone should reach from a search result; the feed | ||
| 54 | + URL itself is the thing worth indexing, and only as a feed. */}} | ||
| 55 | + <meta name="robots" content="noindex"/> | ||
| 56 | + <title><xsl:value-of select="/rss/channel/title"/> · {{ transform.XMLEscape (i18n "feedPageTitle") }}</title> | ||
| 57 | + {{- with $css }} | ||
| 58 | + <link rel="stylesheet" type="text/css" href="{{ transform.XMLEscape . }}"{{ with $integrity }} integrity="{{ transform.XMLEscape . }}"{{ end }}/> | ||
| 59 | + {{- end }} | ||
| 60 | + </head> | ||
| 61 | + <body> | ||
| 62 | + <div class="wrap"> | ||
| 63 | + <header class="masthead column rise"> | ||
| 64 | + <p class="masthead__title"><a href="{{ transform.XMLEscape ("/" | relLangURL) }}">{{ transform.XMLEscape site.Title }}</a></p> | ||
| 65 | + {{- /* The same sha256-of-this-URL curve every page draws, for the | ||
| 66 | + feed's own address - so this page is signed like the rest. */}} | ||
| 67 | + <div class="masthead__signal" aria-hidden="true" title="sha256 of this page's URL, drawn — {{ transform.XMLEscape $signal.hash }}"> | ||
| 68 | + <svg viewBox="0 0 1000 100" preserveAspectRatio="none" focusable="false"> | ||
| 69 | + <defs> | ||
| 70 | + <linearGradient id="signal-ramp" x1="0" y1="0" x2="1" y2="0"> | ||
| 71 | + <stop class="signal-ramp__lead" offset="0"/> | ||
| 72 | + <stop class="signal-ramp__body" offset="0.24"/> | ||
| 73 | + <stop class="signal-ramp__tail" offset="1"/> | ||
| 74 | + </linearGradient> | ||
| 75 | + </defs> | ||
| 76 | + <path d="{{ transform.XMLEscape $signal.path }}" pathLength="100"/> | ||
| 77 | + </svg> | ||
| 78 | + </div> | ||
| 79 | + </header> | ||
| 80 | + | ||
| 81 | + <main id="main"> | ||
| 82 | + <div class="feed-page column"> | ||
| 83 | + <header class="feed-head rise"> | ||
| 84 | + {{- /* The mark says "feed" on its own, so it stands in for the | ||
| 85 | + kicker that used to sit above the title and repeat the word. */}} | ||
| 86 | + <h1 class="post-title feed-title"> | ||
| 87 | + <svg class="feed-title__mark" viewBox="0 0 24 24" aria-hidden="true"> | ||
| 88 | + <path d="M4 11a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 4 13.5V11z"/> | ||
| 89 | + <path d="M4 4a16 16 0 0 1 16 16h-2.5A13.5 13.5 0 0 0 4 6.5V4z"/> | ||
| 90 | + <circle cx="5.5" cy="18.5" r="2"/> | ||
| 91 | + </svg> | ||
| 92 | + <xsl:text>{{ transform.XMLEscape (i18n "feedPageTitle") }}</xsl:text> | ||
| 93 | + </h1> | ||
| 94 | + <hr class="post-rule"/> | ||
| 95 | + </header> | ||
| 96 | + | ||
| 97 | + <div class="feed-intro rise" style="--i: 1"> | ||
| 98 | + <p class="feed-intro__text">{{ transform.XMLEscape (i18n "feedIntro" (dict "Site" site.Title)) }}</p> | ||
| 99 | + <div class="feed-address"> | ||
| 100 | + <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> | ||
| 112 | + </div> | ||
| 113 | + </div> | ||
| 114 | + | ||
| 115 | + <xsl:choose> | ||
| 116 | + <xsl:when test="/rss/channel/item"> | ||
| 117 | + <div class="feed-grid"> | ||
| 118 | + <xsl:for-each select="/rss/channel/item"> | ||
| 119 | + <article class="feed-card rise" style="--i: {position() + 1}"> | ||
| 120 | + <time class="feed-card__date"> | ||
| 121 | + <xsl:attribute name="datetime"> | ||
| 122 | + <xsl:call-template name="iso-date"> | ||
| 123 | + <xsl:with-param name="rfc" select="pubDate"/> | ||
| 124 | + </xsl:call-template> | ||
| 125 | + </xsl:attribute> | ||
| 126 | + <xsl:value-of select="concat(substring(pubDate, 9, 3), ' ', substring(pubDate, 6, 2), ', ', substring(pubDate, 13, 4))"/> | ||
| 127 | + </time> | ||
| 128 | + <h2 class="feed-card__title"> | ||
| 129 | + <a href="{link}"><xsl:value-of select="title"/></a> | ||
| 130 | + </h2> | ||
| 131 | + {{- /* <description> carries the summary's real markup, | ||
| 132 | + escaped by the feed. disable-output-escaping puts it | ||
| 133 | + back, which is why the container is a <div> - a summary | ||
| 134 | + is free to be more than one <p>, and <p><p></p></p> is | ||
| 135 | + not. */}} | ||
| 136 | + <xsl:if test="normalize-space(description) != ''"> | ||
| 137 | + <div class="feed-card__excerpt"> | ||
| 138 | + <xsl:value-of select="description" disable-output-escaping="yes"/> | ||
| 139 | + </div> | ||
| 140 | + </xsl:if> | ||
| 141 | + <span class="feed-card__more" aria-hidden="true">{{ transform.XMLEscape (i18n "readMore") }}</span> | ||
| 142 | + </article> | ||
| 143 | + </xsl:for-each> | ||
| 144 | + </div> | ||
| 145 | + </xsl:when> | ||
| 146 | + <xsl:otherwise> | ||
| 147 | + <p class="feed-empty">{{ transform.XMLEscape (i18n "feedEmpty") }}</p> | ||
| 148 | + </xsl:otherwise> | ||
| 149 | + </xsl:choose> | ||
| 150 | + </div> | ||
| 151 | + </main> | ||
| 152 | + | ||
| 153 | + <footer class="site-foot column"> | ||
| 154 | + <span><a href="{{ transform.XMLEscape ("/" | relLangURL) }}">{{ transform.XMLEscape (i18n "feedBackHome" (dict "Site" site.Title)) }}</a></span> | ||
| 155 | + <span class="sep" aria-hidden="true">·</span> | ||
| 156 | + <span>{{ transform.XMLEscape (i18n "licenseText") }} <a href="{{ transform.XMLEscape (i18n "licenseURL") }}">CC BY-SA</a></span> | ||
| 157 | + </footer> | ||
| 158 | + </div> | ||
| 159 | + </body> | ||
| 160 | + </html> | ||
| 161 | + </xsl:template> | ||
| 162 | + | ||
| 163 | + {{- /* | ||
| 164 | + RFC 822 ("Fri, 13 Apr 1990 09:00:00 +0100") to ISO 8601 ("1990-04-13"), | ||
| 165 | + for <time datetime>. Hugo always zero-pads the day, so every field is at a | ||
| 166 | + fixed offset; the month is the only one needing work, and its number is | ||
| 167 | + just where its abbreviation starts in the list below, divided by three. | ||
| 168 | + */}} | ||
| 169 | + <xsl:template name="iso-date"> | ||
| 170 | + <xsl:param name="rfc"/> | ||
| 171 | + <xsl:variable name="months" select="'JanFebMarAprMayJunJulAugSepOctNovDec'"/> | ||
| 172 | + <xsl:variable name="month" select="substring($rfc, 9, 3)"/> | ||
| 173 | + <xsl:if test="contains($months, $month)"> | ||
| 174 | + <xsl:value-of select="concat( | ||
| 175 | + substring($rfc, 13, 4), '-', | ||
| 176 | + format-number((string-length(substring-before($months, $month)) div 3) + 1, '00'), '-', | ||
| 177 | + substring($rfc, 6, 2))"/> | ||
| 178 | + </xsl:if> | ||
| 179 | + </xsl:template> | ||
| 180 | +</xsl:stylesheet> | ||
added
blog/layouts/_default/rss.xml +120 -0 | new file mode 100644 | ||
| @@ -0,0 +1,120 @@ | ||
| 1 | +{{- /* | |
| 2 | + A copy of Hugo's embedded rss.xml (tpl/tplimpl/embedded/templates/rss.xml, | |
| 3 | + v0.166.0) kept deliberately close to it, so a future Hugo's version can be | |
| 4 | + diffed against this one. Three things are ours: | |
| 5 | + | |
| 6 | + 1. the <?xml-stylesheet?> below, which asks a browser to render the feed | |
| 7 | + through feed.xsl instead of showing its tag tree; | |
| 8 | + 2. the <script>, which does the same job for browsers that no longer run | |
| 9 | + XSLT - see the note in hugo.toml; | |
| 10 | + 3. the channel <description>, which said "Recent content on mykiwi.blog" | |
| 11 | + and now says what the site's own <meta name="description"> says. | |
| 12 | + | |
| 13 | + Both files are home-page outputs, so a section feed (posts/index.xml) has to | |
| 14 | + reach across to site.Home for them rather than ask its own .OutputFormats. | |
| 15 | +*/ -}} | |
| 16 | +{{- $xsl := "" }} | |
| 17 | +{{- with site.Home.OutputFormats.Get "feedstyle" }}{{ $xsl = .RelPermalink }}{{ end }} | |
| 18 | +{{- $js := "" }} | |
| 19 | +{{- with site.Home.OutputFormats.Get "feedscript" }}{{ $js = .RelPermalink }}{{ end }} | |
| 20 | + | |
| 21 | +{{- /* Same precedence as baseof.html's <meta name="description">: an explicit | |
| 22 | + site-wide override first, then the colophon bio flattened to plain text. */}} | |
| 23 | +{{- $desc := site.Params.description }} | |
| 24 | +{{- /* htmlUnescape is the one step baseof.html's otherwise identical pipeline | |
| 25 | + does not need. Goldmark's typographer writes the bio's apostrophe as the | |
| 26 | + named entity ’, which is fine in the HTML attribute baseof puts it in | |
| 27 | + and fatal here: XML predefines five entity names and that is not one of | |
| 28 | + them, so a feed carrying it is malformed and every reader's parser stops at | |
| 29 | + it. Unescaping to the character itself lets the template's own escaping | |
| 30 | + produce well-formed XML. */}} | |
| 31 | +{{- if not $desc }}{{ with i18n "bio" }}{{ $desc = . | markdownify | plainify | htmlUnescape }}{{ end }}{{ end }} | |
| 32 | +{{- if not $desc }}{{ $desc = printf "Recent content on %s" site.Title }}{{ end }} | |
| 33 | + | |
| 34 | +{{- $authorEmail := "" }} | |
| 35 | +{{- with site.Params.author }} | |
| 36 | + {{- if reflect.IsMap . }} | |
| 37 | + {{- with .email }} | |
| 38 | + {{- $authorEmail = . }} | |
| 39 | + {{- end }} | |
| 40 | + {{- end }} | |
| 41 | +{{- end }} | |
| 42 | + | |
| 43 | +{{- $authorName := "" }} | |
| 44 | +{{- with site.Params.author }} | |
| 45 | + {{- if reflect.IsMap . }} | |
| 46 | + {{- with .name }} | |
| 47 | + {{- $authorName = . }} | |
| 48 | + {{- end }} | |
| 49 | + {{- else }} | |
| 50 | + {{- $authorName = . }} | |
| 51 | + {{- end }} | |
| 52 | +{{- end }} | |
| 53 | + | |
| 54 | +{{- $pctx := . }} | |
| 55 | +{{- if .IsHome }}{{ $pctx = .Site }}{{ end }} | |
| 56 | +{{- $pages := slice }} | |
| 57 | +{{- if or $.IsHome $.IsSection }} | |
| 58 | + {{- $pages = $pctx.RegularPages }} | |
| 59 | +{{- else }} | |
| 60 | + {{- $pages = $pctx.Pages }} | |
| 61 | +{{- end }} | |
| 62 | +{{- $limit := .Site.Config.Services.RSS.Limit }} | |
| 63 | +{{- if ge $limit 1 }} | |
| 64 | + {{- $pages = $pages | first $limit }} | |
| 65 | +{{- end }} | |
| 66 | +{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }} | |
| 67 | +{{- with $xsl }} | |
| 68 | +{{ printf "<?xml-stylesheet type=\"text/xsl\" href=%q?>" . | safeHTML }} | |
| 69 | +{{- end }} | |
| 70 | +<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> | |
| 71 | + <channel> | |
| 72 | + <title>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{ . }} on {{ end }}{{ .Site.Title }}{{ end }}</title> | |
| 73 | + <link>{{ .Permalink }}</link> | |
| 74 | + <description>{{ $desc }}</description> | |
| 75 | + <generator>Hugo</generator> | |
| 76 | + <language>{{ site.Language.Locale }}</language> | |
| 77 | + {{ with $authorEmail }} | |
| 78 | + <managingEditor>{{ . }}{{ with $authorName }} ({{ . }}){{ end }}</managingEditor> | |
| 79 | + {{ end }} | |
| 80 | + {{ with $authorEmail }} | |
| 81 | + <webMaster>{{ . }}{{ with $authorName }} ({{ . }}){{ end }}</webMaster> | |
| 82 | + {{ end }} | |
| 83 | + {{ with .Site.Copyright }} | |
| 84 | + <copyright>{{ . }}</copyright> | |
| 85 | + {{ end }} | |
| 86 | + {{ if not .Date.IsZero }} | |
| 87 | + <lastBuildDate>{{ (index $pages.ByLastmod.Reverse 0).Lastmod.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate> | |
| 88 | + {{ end }} | |
| 89 | + {{- with .OutputFormats.Get "RSS" }} | |
| 90 | + {{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }} | |
| 91 | + {{- end }} | |
| 92 | + {{- with $js }} | |
| 93 | + {{- /* | |
| 94 | + An element in the XHTML namespace, which is what makes a browser | |
| 95 | + treat it as a script and run it while displaying this XML; every | |
| 96 | + feed reader sees an element in a namespace it does not know and | |
| 97 | + skips it. It only ever has work to do when feed.xsl was not | |
| 98 | + applied - a transform's output contains what the stylesheet emits | |
| 99 | + and nothing else, so under XSLT this tag never reaches the page. | |
| 100 | + | |
| 101 | + Scripts in an XML document run synchronously as the parser reaches | |
| 102 | + them, which here is before a single <item> has been parsed. feed.js | |
| 103 | + waits for DOMContentLoaded before reading anything; that is not | |
| 104 | + belt-and-braces, it is the difference between a full page and one | |
| 105 | + holding only the title. | |
| 106 | + */}} | |
| 107 | + {{ printf "<script xmlns=\"http://www.w3.org/1999/xhtml\" src=%q></script>" . | safeHTML }} | |
| 108 | + {{- end }} | |
| 109 | + {{- range $pages }} | |
| 110 | + <item> | |
| 111 | + <title>{{ .Title }}</title> | |
| 112 | + <link>{{ .Permalink }}</link> | |
| 113 | + <pubDate>{{ .PublishDate.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate> | |
| 114 | + {{- with $authorEmail }}<author>{{ . }}{{ with $authorName }} ({{ . }}){{ end }}</author>{{ end }} | |
| 115 | + <guid>{{ .Permalink }}</guid> | |
| 116 | + <description>{{ .Summary | transform.XMLEscape | safeHTML }}</description> | |
| 117 | + </item> | |
| 118 | + {{- end }} | |
| 119 | + </channel> | |
| 120 | +</rss> | |
| new file mode 100644 | |||
| @@ -0,0 +1,120 @@ | |||
| 1 | +{{- /* | ||
| 2 | + A copy of Hugo's embedded rss.xml (tpl/tplimpl/embedded/templates/rss.xml, | ||
| 3 | + v0.166.0) kept deliberately close to it, so a future Hugo's version can be | ||
| 4 | + diffed against this one. Three things are ours: | ||
| 5 | + | ||
| 6 | + 1. the <?xml-stylesheet?> below, which asks a browser to render the feed | ||
| 7 | + through feed.xsl instead of showing its tag tree; | ||
| 8 | + 2. the <script>, which does the same job for browsers that no longer run | ||
| 9 | + XSLT - see the note in hugo.toml; | ||
| 10 | + 3. the channel <description>, which said "Recent content on mykiwi.blog" | ||
| 11 | + and now says what the site's own <meta name="description"> says. | ||
| 12 | + | ||
| 13 | + Both files are home-page outputs, so a section feed (posts/index.xml) has to | ||
| 14 | + reach across to site.Home for them rather than ask its own .OutputFormats. | ||
| 15 | +*/ -}} | ||
| 16 | +{{- $xsl := "" }} | ||
| 17 | +{{- with site.Home.OutputFormats.Get "feedstyle" }}{{ $xsl = .RelPermalink }}{{ end }} | ||
| 18 | +{{- $js := "" }} | ||
| 19 | +{{- with site.Home.OutputFormats.Get "feedscript" }}{{ $js = .RelPermalink }}{{ end }} | ||
| 20 | + | ||
| 21 | +{{- /* Same precedence as baseof.html's <meta name="description">: an explicit | ||
| 22 | + site-wide override first, then the colophon bio flattened to plain text. */}} | ||
| 23 | +{{- $desc := site.Params.description }} | ||
| 24 | +{{- /* htmlUnescape is the one step baseof.html's otherwise identical pipeline | ||
| 25 | + does not need. Goldmark's typographer writes the bio's apostrophe as the | ||
| 26 | + named entity ’, which is fine in the HTML attribute baseof puts it in | ||
| 27 | + and fatal here: XML predefines five entity names and that is not one of | ||
| 28 | + them, so a feed carrying it is malformed and every reader's parser stops at | ||
| 29 | + it. Unescaping to the character itself lets the template's own escaping | ||
| 30 | + produce well-formed XML. */}} | ||
| 31 | +{{- if not $desc }}{{ with i18n "bio" }}{{ $desc = . | markdownify | plainify | htmlUnescape }}{{ end }}{{ end }} | ||
| 32 | +{{- if not $desc }}{{ $desc = printf "Recent content on %s" site.Title }}{{ end }} | ||
| 33 | + | ||
| 34 | +{{- $authorEmail := "" }} | ||
| 35 | +{{- with site.Params.author }} | ||
| 36 | + {{- if reflect.IsMap . }} | ||
| 37 | + {{- with .email }} | ||
| 38 | + {{- $authorEmail = . }} | ||
| 39 | + {{- end }} | ||
| 40 | + {{- end }} | ||
| 41 | +{{- end }} | ||
| 42 | + | ||
| 43 | +{{- $authorName := "" }} | ||
| 44 | +{{- with site.Params.author }} | ||
| 45 | + {{- if reflect.IsMap . }} | ||
| 46 | + {{- with .name }} | ||
| 47 | + {{- $authorName = . }} | ||
| 48 | + {{- end }} | ||
| 49 | + {{- else }} | ||
| 50 | + {{- $authorName = . }} | ||
| 51 | + {{- end }} | ||
| 52 | +{{- end }} | ||
| 53 | + | ||
| 54 | +{{- $pctx := . }} | ||
| 55 | +{{- if .IsHome }}{{ $pctx = .Site }}{{ end }} | ||
| 56 | +{{- $pages := slice }} | ||
| 57 | +{{- if or $.IsHome $.IsSection }} | ||
| 58 | + {{- $pages = $pctx.RegularPages }} | ||
| 59 | +{{- else }} | ||
| 60 | + {{- $pages = $pctx.Pages }} | ||
| 61 | +{{- end }} | ||
| 62 | +{{- $limit := .Site.Config.Services.RSS.Limit }} | ||
| 63 | +{{- if ge $limit 1 }} | ||
| 64 | + {{- $pages = $pages | first $limit }} | ||
| 65 | +{{- end }} | ||
| 66 | +{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }} | ||
| 67 | +{{- with $xsl }} | ||
| 68 | +{{ printf "<?xml-stylesheet type=\"text/xsl\" href=%q?>" . | safeHTML }} | ||
| 69 | +{{- end }} | ||
| 70 | +<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> | ||
| 71 | + <channel> | ||
| 72 | + <title>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{ . }} on {{ end }}{{ .Site.Title }}{{ end }}</title> | ||
| 73 | + <link>{{ .Permalink }}</link> | ||
| 74 | + <description>{{ $desc }}</description> | ||
| 75 | + <generator>Hugo</generator> | ||
| 76 | + <language>{{ site.Language.Locale }}</language> | ||
| 77 | + {{ with $authorEmail }} | ||
| 78 | + <managingEditor>{{ . }}{{ with $authorName }} ({{ . }}){{ end }}</managingEditor> | ||
| 79 | + {{ end }} | ||
| 80 | + {{ with $authorEmail }} | ||
| 81 | + <webMaster>{{ . }}{{ with $authorName }} ({{ . }}){{ end }}</webMaster> | ||
| 82 | + {{ end }} | ||
| 83 | + {{ with .Site.Copyright }} | ||
| 84 | + <copyright>{{ . }}</copyright> | ||
| 85 | + {{ end }} | ||
| 86 | + {{ if not .Date.IsZero }} | ||
| 87 | + <lastBuildDate>{{ (index $pages.ByLastmod.Reverse 0).Lastmod.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate> | ||
| 88 | + {{ end }} | ||
| 89 | + {{- with .OutputFormats.Get "RSS" }} | ||
| 90 | + {{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }} | ||
| 91 | + {{- end }} | ||
| 92 | + {{- with $js }} | ||
| 93 | + {{- /* | ||
| 94 | + An element in the XHTML namespace, which is what makes a browser | ||
| 95 | + treat it as a script and run it while displaying this XML; every | ||
| 96 | + feed reader sees an element in a namespace it does not know and | ||
| 97 | + skips it. It only ever has work to do when feed.xsl was not | ||
| 98 | + applied - a transform's output contains what the stylesheet emits | ||
| 99 | + and nothing else, so under XSLT this tag never reaches the page. | ||
| 100 | + | ||
| 101 | + Scripts in an XML document run synchronously as the parser reaches | ||
| 102 | + them, which here is before a single <item> has been parsed. feed.js | ||
| 103 | + waits for DOMContentLoaded before reading anything; that is not | ||
| 104 | + belt-and-braces, it is the difference between a full page and one | ||
| 105 | + holding only the title. | ||
| 106 | + */}} | ||
| 107 | + {{ printf "<script xmlns=\"http://www.w3.org/1999/xhtml\" src=%q></script>" . | safeHTML }} | ||
| 108 | + {{- end }} | ||
| 109 | + {{- range $pages }} | ||
| 110 | + <item> | ||
| 111 | + <title>{{ .Title }}</title> | ||
| 112 | + <link>{{ .Permalink }}</link> | ||
| 113 | + <pubDate>{{ .PublishDate.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate> | ||
| 114 | + {{- with $authorEmail }}<author>{{ . }}{{ with $authorName }} ({{ . }}){{ end }}</author>{{ end }} | ||
| 115 | + <guid>{{ .Permalink }}</guid> | ||
| 116 | + <description>{{ .Summary | transform.XMLEscape | safeHTML }}</description> | ||
| 117 | + </item> | ||
| 118 | + {{- end }} | ||
| 119 | + </channel> | ||
| 120 | +</rss> | ||