From 929c06d95785cf6258ae12ba1b9e2f39870e710f Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Thu, 28 May 2026 19:10:35 +0100 Subject: [PATCH] site: fetch latest version client-side instead of embedding at build time (#2072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 , 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. --- site/package.json | 1 - site/src/data/github.js | 27 ---------- site/src/data/site.json | 1 + site/src/includes/components/head.njk | 2 +- site/src/includes/components/header.njk | 4 +- site/src/includes/inline.js | 6 --- site/src/includes/inline.njk | 65 +++++++++++++++++++++++++ site/yarn.lock | 39 --------------- 8 files changed, 69 insertions(+), 76 deletions(-) delete mode 100644 site/src/data/github.js delete mode 100644 site/src/includes/inline.js create mode 100644 site/src/includes/inline.njk diff --git a/site/package.json b/site/package.json index d2875153..12872dde 100644 --- a/site/package.json +++ b/site/package.json @@ -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" diff --git a/site/src/data/github.js b/site/src/data/github.js deleted file mode 100644 index 6098bddc..00000000 --- a/site/src/data/github.js +++ /dev/null @@ -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 - } -} diff --git a/site/src/data/site.json b/site/src/data/site.json index d76cd0ed..8e45c6ba 100644 --- a/site/src/data/site.json +++ b/site/src/data/site.json @@ -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" } diff --git a/site/src/includes/components/head.njk b/site/src/includes/components/head.njk index 62303099..ce224e47 100644 --- a/site/src/includes/components/head.njk +++ b/site/src/includes/components/head.njk @@ -12,7 +12,7 @@ {% endif %} - + {% set js %} {% include "script.js" %} {% endset %} diff --git a/site/src/includes/components/header.njk b/site/src/includes/components/header.njk index c3ae6d31..fb0f69a1 100644 --- a/site/src/includes/components/header.njk +++ b/site/src/includes/components/header.njk @@ -8,8 +8,8 @@ Docs
- - {{ github.latestVersion }} + + diff --git a/site/src/includes/inline.js b/site/src/includes/inline.js deleted file mode 100644 index 45ab1bd5..00000000 --- a/site/src/includes/inline.js +++ /dev/null @@ -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') -} diff --git a/site/src/includes/inline.njk b/site/src/includes/inline.njk new file mode 100644 index 00000000..136d1a8f --- /dev/null +++ b/site/src/includes/inline.njk @@ -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 , so a cache-hit fetch can resolve +// before 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)) +} diff --git a/site/yarn.lock b/site/yarn.lock index 505430cb..f477f78e 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -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"