site: fetch latest version client-side instead of embedding at build time (#2072)

* site: fetch latest version client-side instead of embedding at build time

The header version badge was filled in by site/src/data/github.js calling
the GitHub releases API at Eleventy build time and baking data[0].tag_name
into every page. This had three failure modes:

1. Layered cache: Buildx caches the yarn build layer; on a release-triggered
   workflow nothing under ./site changes, so the cached HTML (with the
   previous tag baked in) gets shipped. v1.16.0 went out and remark42.com
   kept showing v1.15.0 until a separate site/ commit landed and naturally
   invalidated the COPY layer.

2. Tag mismatch: the deploy pulls ghcr.io/umputun/remark42-site:master,
   but release events build :v1.16.0 and :latest only. A cache-skip
   workflow tweak wouldn't even reach the served image.

3. API propagation race: the workflow fires ~2s after release publish,
   so even with cache disabled the API might still return the previous
   tag from a stale read replica.

All three vanish if the version is fetched in the browser. GitHub serves
the /releases/latest response with Cache-Control: public, max-age=60 so
per-visitor cost is bounded; failures fall through silently and the
badge stays empty rather than wrong.

Changes:
- header.njk: replace {{ github.latestVersion }} with a
  <span data-remark42-version></span> placeholder.
- inline.js: add a fetch of /releases/latest that fills any
  [data-remark42-version] element on the page. fallback is no-op on any
  network/parse failure.
- delete site/src/data/github.js (Eleventy data file is no longer used).
- drop node-fetch from devDependencies (was used only by github.js).

* site: address PR review on version badge fetch

- gate DOM update on DOMContentLoaded — inline.js is loaded sync in <head>,
  so a cache-hit fetch can resolve before the placeholder span is parsed.
- hide placeholder span by default (`hidden`) so a failed/blocked fetch
  doesn't leave a 0.5rem stray gap before the github icon.
- log fetch failures (rate limit, offline, blocked) instead of silently
  swallowing — matches the prior behaviour of build-time github.js.

* site: cache latest version in sessionStorage with 1h TTL

avoids hitting the GitHub API on every page load — repeated navigations
within a tab read from sessionStorage instead. TTL caps stale display at
1h for very long-lived tabs. cleared on tab close, so each new session
fetches once and reuses the result throughout.

* site: address PR review on header & version fetch

Copilot review on the cache commit raised three points:

1. inline.js had a hard-coded `https://api.github.com/repos/umputun/remark42`
   while the templates use `site.githubUrl`. Rename inline.js → inline.njk
   so nunjucks evaluates it, add `githubApiUrl` to site.json, and template
   the fetch URL from it. One place to update if the repo ever moves.

2. header.njk aria-label said "Remark42's GitHub Repository" but the link
   target is `/releases`. Change to "{{ site.name }} releases on GitHub"
   so screen readers describe the actual destination.

3. console.warn on fetch failure (kept after umputun's prior review noted
   the trade-off): addressed in the PR description, no code change.

* site: actually template fetch URL via site.githubApiUrl

Copilot's second pass caught that 7697dcf3 added site.githubApiUrl,
renamed inline.js → inline.njk, and pointed head.njk at the .njk file
— but the fetch() call itself was never changed to use the template
variable. Build output looked correct because the literal hard-coded
URL happened to match what {{ site.githubApiUrl }} would expand to.

* site: normalise Nunjucks spacing in header.njk

{{ site.githubUrl}}/releases → {{ site.githubUrl }}/releases. Cosmetic
only; matches the spacing used everywhere else in the templates.
This commit is contained in:
Dmitry Verkhoturov
2026-05-28 13:10:35 -05:00
committed by GitHub
parent 198efddb54
commit 929c06d957
8 changed files with 69 additions and 76 deletions
-1
View File
@@ -31,7 +31,6 @@
"markdown-it": "^14.0.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-container": "^4.0.0",
"node-fetch": "^3.3.2",
"npm-run-all": "^4.1.5",
"prettier": "^3.4.1",
"tailwindcss": "^3.4.15"
-27
View File
@@ -1,27 +0,0 @@
const DEFAULT_DATA = { latestVersion: '' }
let currentData = null
module.exports = async function getLatestReleaseVersion() {
if (currentData) {
return currentData
}
try {
const fetch = (await import('node-fetch')).default;
const res = await fetch(
'https://api.github.com/repos/umputun/remark42/releases'
)
if (!res.ok) {
throw new Error(`[ERROR] Status: ${res.status}: ${res.statusText}`)
}
const data = await res.json()
currentData = { latestVersion: data[0].tag_name || '' }
return currentData
} catch (e) {
console.error(e.message)
return DEFAULT_DATA
}
}
+1
View File
@@ -4,6 +4,7 @@
"description": "Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles or any other place where readers add comments.",
"url": "https://remark42.com",
"githubUrl": "https://github.com/umputun/remark42",
"githubApiUrl": "https://api.github.com/repos/umputun/remark42",
"githubBranch": "master",
"remark42Host": "https://demo.remark42.com"
}
+1 -1
View File
@@ -12,7 +12,7 @@
{% endif %}
<meta name="description" content="{{ metaDescription or renderData.metaDescription or site.description }}">
<link rel="stylesheet" href="/style.css?v={% version %}"/>
<script>{% include "inline.js" %}</script>
<script>{% include "inline.njk" %}</script>
{% set js %}
{% include "script.js" %}
{% endset %}
+2 -2
View File
@@ -8,8 +8,8 @@
<a class="inline-block leading-5 font-medium border-b dark:border-gray-600 transition hover:text-brand-600 dark:hover:text-brand-300 hover:border-brand-300 dark:hover:border-brand-500" href="/docs/getting-started/installation/">Docs</a>
</nav>
<div class="order-2 md:order-3 flex space-x-2 ml-auto py-1 px-4">
<a class="hover:text-gray-800 dark:hover:text-gray-300 flex items-center" aria-label="Remark42's GitHub Repository" rel="noopener noreferrer" href="{{ site.githubUrl}}/releases" target="_blank">
<span class="mr-2">{{ github.latestVersion }}</span>
<a class="hover:text-gray-800 dark:hover:text-gray-300 flex items-center" aria-label="{{ site.name }} releases on GitHub" rel="noopener noreferrer" href="{{ site.githubUrl }}/releases" target="_blank">
<span class="mr-2" data-remark42-version hidden></span>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path fill="currentColor" d="M12 3C7.0275 3 3 7.12937 3 12.2276C3 16.3109 5.57625 19.7597 9.15374 20.9824C9.60374 21.0631 9.77249 20.7863 9.77249 20.5441C9.77249 20.3249 9.76125 19.5982 9.76125 18.8254C7.5 19.2522 6.915 18.2602 6.735 17.7412C6.63375 17.4759 6.19499 16.6569 5.8125 16.4378C5.4975 16.2647 5.0475 15.838 5.80124 15.8264C6.51 15.8149 7.01625 16.4954 7.18499 16.7723C7.99499 18.1679 9.28875 17.7758 9.80625 17.5335C9.885 16.9337 10.1212 16.53 10.38 16.2993C8.3775 16.0687 6.285 15.2728 6.285 11.7432C6.285 10.7397 6.63375 9.9092 7.20749 9.26326C7.1175 9.03257 6.8025 8.08674 7.2975 6.81794C7.2975 6.81794 8.05125 6.57571 9.77249 7.76377C10.4925 7.55615 11.2575 7.45234 12.0225 7.45234C12.7875 7.45234 13.5525 7.55615 14.2725 7.76377C15.9937 6.56418 16.7475 6.81794 16.7475 6.81794C17.2424 8.08674 16.9275 9.03257 16.8375 9.26326C17.4113 9.9092 17.76 10.7281 17.76 11.7432C17.76 15.2843 15.6563 16.0687 13.6537 16.2993C13.98 16.5877 14.2613 17.1414 14.2613 18.0065C14.2613 19.2407 14.25 20.2326 14.25 20.5441C14.25 20.7863 14.4188 21.0746 14.8688 20.9824C16.6554 20.364 18.2079 19.1866 19.3078 17.6162C20.4077 16.0457 20.9995 14.1611 21 12.2276C21 7.12937 16.9725 3 12 3Z"></path>
</svg>
-6
View File
@@ -1,6 +0,0 @@
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const theme = localStorage.getItem('theme')
if ((theme && theme === 'dark') || (!theme && mq.matches)) {
document.documentElement.classList.add('dark')
}
+65
View File
@@ -0,0 +1,65 @@
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const theme = localStorage.getItem('theme')
if ((theme && theme === 'dark') || (!theme && mq.matches)) {
document.documentElement.classList.add('dark')
}
// fetch the latest release version client-side so the badge tracks releases
// without needing a site rebuild. cached in sessionStorage for up to 1h so
// repeated navigations within a tab don't re-hit the GitHub API on every page
// load. placeholder is `hidden` in the template so a failed/blocked fetch
// leaves no stray gap.
const versionCacheKey = 'remark42-latest-version'
const versionCacheTTL = 60 * 60 * 1000
// script is loaded synchronously in <head>, so a cache-hit fetch can resolve
// before <body> is parsed and [data-remark42-version] exists. defer the DOM
// update until the document is ready.
function applyVersion(tag) {
const update = () => {
document.querySelectorAll('[data-remark42-version]').forEach((el) => {
el.textContent = tag
el.hidden = false
})
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', update)
} else {
update()
}
}
function readVersionCache() {
try {
const raw = sessionStorage.getItem(versionCacheKey)
if (!raw) return null
const { tag, expires } = JSON.parse(raw)
if (!tag || typeof expires !== 'number' || Date.now() > expires) return null
return tag
} catch (e) {
return null
}
}
function writeVersionCache(tag) {
try {
sessionStorage.setItem(versionCacheKey, JSON.stringify({ tag, expires: Date.now() + versionCacheTTL }))
} catch (e) {
// sessionStorage unavailable or full — fall through, fetch will run next load
}
}
const cachedVersion = readVersionCache()
if (cachedVersion) {
applyVersion(cachedVersion)
} else {
fetch('{{ site.githubApiUrl }}/releases/latest')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))))
.then((d) => {
if (!d || !d.tag_name) return
writeVersionCache(d.tag_name)
applyVersion(d.tag_name)
})
.catch((err) => console.warn('remark42-site: latest version fetch failed', err))
}
-39
View File
@@ -598,11 +598,6 @@ cssesc@^3.0.0:
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
data-uri-to-buffer@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
data-view-buffer@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2"
@@ -922,14 +917,6 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"
fetch-blob@^3.1.2, fetch-blob@^3.1.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==
dependencies:
node-domexception "^1.0.0"
web-streams-polyfill "^3.0.3"
filelist@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5"
@@ -972,13 +959,6 @@ foreground-child@^3.1.0:
cross-spawn "^7.0.0"
signal-exit "^4.0.1"
formdata-polyfill@^4.0.10:
version "4.0.10"
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==
dependencies:
fetch-blob "^3.1.2"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -1770,20 +1750,6 @@ no-case@^2.2.0:
dependencies:
lower-case "^1.1.1"
node-domexception@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
node-fetch@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b"
integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==
dependencies:
data-uri-to-buffer "^4.0.0"
fetch-blob "^3.1.4"
formdata-polyfill "^4.0.10"
normalize-package-data@^2.3.2:
version "2.5.0"
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
@@ -2750,11 +2716,6 @@ void-elements@^3.1.0:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==
web-streams-polyfill@^3.0.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
which-boxed-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"