From a783e56b774795c1f4bd8a6088dfcce96a307c5f Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 25 Sep 2026 17:11:19 +0200 Subject: ci: port the Cloudflare Workers docs pipeline to circinus (slug 1.5) (#2208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: port Cloudflare Workers docs pipeline files to circinus (verbatim) Copies the branch-agnostic half of the docs.vyos.io Cloudflare Workers pipeline from `rolling` at 8cb568bf, byte-identical: - .github/workflows/docs-build.yml - workers/ (entire tree) - scripts/docs_gates/ - docker/im-convert.sh - docs/_static/js/version-picker.js, js/pagefind-wrapper.js, css/version-picker.css (new files, no circinus counterpart) - docs/_templates/breadcrumbs.html, searchbox.html (new files) docs-build.yml already triggers on push to [rolling, circinus, sagitta] and resolves `circinus` -> worker vyos-docs-v15-en / slug 1.5 from workers/matrix.json; the files simply did not exist on this branch, so slug 1.5 still serves the bootstrap placeholder. workers/versions.json + workers/matrix.json are deliberately identical across all three branches and must be kept in sync. Advances: IS-572 * ci: wire circinus docs build into the Cloudflare Workers pipeline Hand-merges the CF-specific hunks onto circinus's own docker/Dockerfile and docs/conf.py rather than clobbering them with rolling's versions — circinus keeps its own content-driven history in both files. docker/Dockerfile: - imagemagick + librsvg2-bin (sphinx.ext.imgconverter backend) and poppler-utils (pdfinfo, used by docs-build.yml's PDF page-count completeness check), installed --no-install-recommends - install docker/im-convert.sh as /usr/local/bin/im-convert docs/conf.py: - enable sphinx.ext.imgconverter + image_converter = 'im-convert' so the LaTeX/PDF builder stops silently dropping .webp/.svg images - register js/version-picker.js + css/version-picker.css unconditionally (degrades silently on ReadTheDocs) - _vyos_cf_build gate off the raw DOCS_VERSION_SLUG env var; only CF builds load js/pagefind-wrapper.js, and html_context['vyos_cf_build'] lets _templates/searchbox.html fall back to the stock Sphinx searchbox via the "!" bang-include on RTD The RTD path stays the default in both files: circinus continues building on ReadTheDocs until RTD sunset, and every CF feature activates only when DOCS_VERSION_SLUG is present. .readthedocs.yml is untouched. circinus keeps its own version/release/html_title/source_suffix and its hardcoded html_baseurl (its CF slug is also `1.5`, so rolling's DOCS_VERSION_SLUG/READTHEDOCS_VERSION resolution block is a no-op here and was deliberately not ported). Advances: IS-572 * ci: IS-572: re-sync ported Cloudflare Workers pipeline files with rolling The category-1 files in this port are byte-identical copies from `rolling`. `rolling` has since moved: [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) merged as `3a1c6c30`, thirteen rounds of hardening on exactly these files. Re-take all 14 category-1 paths from `origin/rolling` via `git checkout origin/rolling -- `, so byte-identity holds by construction rather than by hand-editing: .github/workflows/docs-build.yml scripts/docs_gates/{gates,parity,smoke,test_gates,test_parity,test_smoke}.py workers/.gitignore workers/apex/src/{index,special,uagate}.ts workers/apex/test/{router,uagate}.test.ts workers/apex/ua-policy.json Thirteen of the fourteen carry [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) exactly — the pre-change tree was byte-identical to `3a1c6c30^` for those paths. `workers/.gitignore` additionally picks up the one-line `test-results/` entry from [vyos-documentation#2212](https://github.com/vyos/vyos-documentation/pull/2212); inert on circinus, since only the deliberately-unported `apex-deploy.yml` writes that directory. Deliberate exclusions are unchanged: `docs-canary-qa.yml` (cron runs on the default branch only, so it is not ported even though [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) touched it on `rolling`), `apex-deploy.yml`, and the `docs-preview-*` workflows. `docs/conf.py` stays hand-merged and circinus-specific, with its ReadTheDocs fallback intact. 🤖 Generated by [robots](https://vyos.io) --- docs/_static/js/pagefind-wrapper.js | 69 +++++++++++++ docs/_static/js/version-picker.js | 193 ++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 docs/_static/js/pagefind-wrapper.js create mode 100644 docs/_static/js/version-picker.js (limited to 'docs/_static/js') diff --git a/docs/_static/js/pagefind-wrapper.js b/docs/_static/js/pagefind-wrapper.js new file mode 100644 index 00000000..a4acadf8 --- /dev/null +++ b/docs/_static/js/pagefind-wrapper.js @@ -0,0 +1,69 @@ +/* Version-scoped Pagefind loader. Derives its base from the current URL so the + * same file works on production, canary, and /pr-/ previews (§9). */ +(function (window) { + 'use strict'; + + function basePathFor(pathname) { + var m = pathname.match(/^((\/pr-\d+)?\/[a-z]{2}(?:_[A-Z]{2})?\/[^/]+\/)/); + if (!m) return null; + return { base: m[1], prefix: m[2] || '' }; + } + + function prefixResultUrl(url, prefix) { + return prefix ? prefix + url : url; + } + + function assetUrlsFor(base) { + return { + css: base + 'pagefind/pagefind-ui.css', + js: base + 'pagefind/pagefind-ui.js', + }; + } + + function init() { + var mount = document.getElementById('vyos-search'); + if (!mount) return; + var ctx = basePathFor(window.location.pathname); + if (!ctx) return; + var assets = assetUrlsFor(ctx.base); + /* Stylesheet first so the UI never renders unstyled. CSS load failure is + * cosmetic only — no error handler on the link. */ + var l = document.createElement('link'); + l.rel = 'stylesheet'; + l.href = assets.css; + document.head.appendChild(l); + var s = document.createElement('script'); + s.src = assets.js; + s.onerror = function () { + /* Per-version Pagefind assets missing (index not built yet, preview + * namespace, transient failure) — show a visible notice instead of + * leaving #vyos-search a silent empty div. */ + var p = document.createElement('p'); + p.className = 'vyos-search-unavailable'; + p.textContent = 'Search is temporarily unavailable for this version.'; + mount.appendChild(p); + }; + s.onload = function () { + /* global PagefindUI */ + new window.PagefindUI({ + element: '#vyos-search', + baseUrl: ctx.base, + bundlePath: ctx.base + 'pagefind/', + processResult: function (result) { + result.url = prefixResultUrl(result.url, ctx.prefix); + return result; + }, + }); + }; + document.head.appendChild(s); + } + + window.VyOSSearch = { + basePathFor: basePathFor, + prefixResultUrl: prefixResultUrl, + assetUrlsFor: assetUrlsFor, + init: init, + }; + if (typeof document !== 'undefined' && document.addEventListener) + document.addEventListener('DOMContentLoaded', init); +})(window); diff --git a/docs/_static/js/version-picker.js b/docs/_static/js/version-picker.js new file mode 100644 index 00000000..84f68fd6 --- /dev/null +++ b/docs/_static/js/version-picker.js @@ -0,0 +1,193 @@ +/* VyOS docs version picker + status banner + language scaffold. + * Vanilla JS, no dependencies, no build step. Degrades silently when + * /versions.json is unreachable (docs stay fully readable). */ +(function (window) { + 'use strict'; + + // Deliberately does not match /pr-/ preview prefixes — previews are single-version, + // so the picker has nothing to switch between and stays hidden there by design. + // The (?!\.\.?\/) guard rejects the dot-segments "." and ".." as slugs, which would + // otherwise escape the /// tree once the browser normalizes the URL. + function parseLocation(pathname) { + var m = pathname.match(/^\/([a-z]{2}(?:_[A-Z]{2})?)\/(?!\.\.?\/)([A-Za-z0-9._-]+)\/(.*)$/); + if (!m) return null; + return { lang: m[1], slug: m[2], rest: m[3] }; + } + + // Contract: versions.json lists versions newest-first, so the first 'lts' entry found + // here is the newest LTS — callers rely on that ordering rather than comparing versions. + function newestLts(manifest) { + for (var i = 0; i < manifest.versions.length; i++) + if (manifest.versions[i].status === 'lts') return manifest.versions[i].slug; + return null; + } + + function bannerFor(slug, manifest) { + var entry = null, i; + for (i = 0; i < manifest.versions.length; i++) + if (manifest.versions[i].slug === slug) entry = manifest.versions[i]; + if (!entry) return null; + var newest = newestLts(manifest); + if (entry.status === 'dev') return { kind: 'dev', newest: newest }; + if (entry.status === 'eol') return { kind: 'eol', newest: newest }; + if (entry.status === 'lts' && newest && newest !== slug) + return { kind: 'newer-lts', newest: newest }; + return null; + } + + /* Normalize one path segment: each well-formed %HH run is decoded and re-encoded on its + * own — per run, not per segment, so one malformed escape cannot double-encode the valid + * ones beside it (location.pathname hands escapes back verbatim, and blind re-encoding + * would turn %2E into %252E and miss on the HEAD probe). Literal spans, including a bare + * '%', always go through encodeURIComponent, so taint neutralization holds unconditionally; + * a run decoding to invalid UTF-8 is kept verbatim, being already pure %HH text. + * Decoding cannot resurrect a dot-segment: the URL parser resolves '.' / '..' and their + * percent-encoded forms during navigation, so pathname never presents them. */ + function encodeSegment(seg) { + var out = ''; + var re = /(?:%[0-9A-Fa-f]{2})+/g; + var last = 0; + var m; + while ((m = re.exec(seg))) { + out += encodeURIComponent(seg.slice(last, m.index)); + try { out += encodeURIComponent(decodeURIComponent(m[0])); } catch (e) { out += m[0]; } + last = m.index + m[0].length; + } + return out + encodeURIComponent(seg.slice(last)); + } + + /* Percent-encode a multi-segment path one segment at a time, so the '/' separators + * survive. Real docs paths are plain ASCII sphinx slugs (letters/digits/-/_/./html), + * where this is a no-op — it exists to keep DOM-derived text (location.pathname, + * select.value) from reaching a location.href sink unescaped. */ + function encodePath(rest) { + return rest.split('/').map(function (seg) { return encodeSegment(seg); }).join('/'); + } + + function targetUrlFor(loc, targetSlug) { + return '/' + encodeURIComponent(loc.lang) + '/' + encodeURIComponent(targetSlug) + + '/' + encodePath(loc.rest); + } + + /* Full navigation URL for a version switch: same path on the target version + * with the current query string + fragment re-attached, so deep links + * (?highlight=…, #section) survive the switch (§4 URL-stability contract). */ + function navUrlFor(loc, targetSlug, search, hash) { + return targetUrlFor(loc, targetSlug) + (search || '') + (hash || ''); + } + + /* Mirror of targetUrlFor for the language switch: swaps the lang segment while + * keeping the current version slug and path. */ + function langUrlFor(loc, langCode) { + return '/' + encodeURIComponent(langCode) + '/' + encodeURIComponent(loc.slug) + + '/' + encodePath(loc.rest); + } + + /* ---- DOM layer (no execution at import time) ---- */ + function bannerText(b, manifest) { + if (b.kind === 'dev') return 'You are reading the development (rolling) docs.'; + if (b.kind === 'eol') return 'This VyOS version is end-of-life; these docs are frozen. See the ' + b.newest + ' (LTS) docs.'; + return 'A newer LTS (' + b.newest + ') is available.'; + } + + function init() { + var anchor = document.getElementById('vyos-version-picker'); + if (!anchor) return; + var loc = parseLocation(window.location.pathname); + if (!loc) return; + + fetch('/versions.json', { headers: { accept: 'application/json' } }) + .then(function (r) { if (!r.ok) throw new Error('versions.json ' + r.status); return r.json(); }) + .then(function (manifest) { + renderPicker(anchor, loc, manifest); + renderLang(anchor, loc, manifest); + renderBanner(loc, manifest); + }) + .catch(function () { /* silent degradation (§4) */ }); + } + + function renderPicker(anchor, loc, manifest) { + var label = document.createElement('label'); + label.setAttribute('for', 'vyos-version-select'); + label.textContent = 'Version: '; + var sel = document.createElement('select'); + sel.id = 'vyos-version-select'; + manifest.versions.forEach(function (v) { + var o = document.createElement('option'); + o.value = v.slug; o.textContent = v.label; o.selected = v.slug === loc.slug; + sel.appendChild(o); + }); + sel.addEventListener('change', function () { + var search = window.location.search, hash = window.location.hash; + var target = navUrlFor(loc, sel.value, search, hash); + var fallback = '/' + encodeURIComponent(loc.lang) + '/' + encodeURIComponent(sel.value) + + '/' + (search || '') + (hash || ''); + fetch(targetUrlFor(loc, sel.value), { method: 'HEAD' }) + .then(function (r) { + window.location.href = (r.status === 404) ? fallback : target; + }) + .catch(function () { window.location.href = fallback; }); + }); + anchor.appendChild(label); + anchor.appendChild(sel); + + var entry = manifest.versions.filter(function (v) { return v.slug === loc.slug; })[0]; + if (entry && entry.pdf) { + var a = document.createElement('a'); + a.href = entry.pdf; a.className = 'vyos-pdf-link'; a.textContent = 'PDF'; + anchor.appendChild(a); + } + } + + function renderLang(anchor, loc, manifest) { + if (!manifest.languages || manifest.languages.length <= 1) return; // scaffold: hidden while en-only (§4) + var sel = document.createElement('select'); + sel.id = 'vyos-lang-select'; + sel.setAttribute('aria-label', 'Language'); + manifest.languages.forEach(function (l) { + var o = document.createElement('option'); + o.value = l.code; o.textContent = l.label; o.selected = l.code === loc.lang; + sel.appendChild(o); + }); + sel.addEventListener('change', function () { + window.location.href = langUrlFor(loc, sel.value) + + window.location.search + window.location.hash; + }); + anchor.appendChild(sel); + } + + function renderBanner(loc, manifest) { + var b = bannerFor(loc.slug, manifest); + if (!b) return; + var key = 'vyos-banner-dismissed-' + loc.slug; + try { if (window.localStorage.getItem(key)) return; } catch (e) { /* private mode */ } + var div = document.createElement('div'); + div.className = 'vyos-version-banner vyos-banner-' + b.kind; + div.setAttribute('role', b.kind === 'eol' ? 'alert' : 'note'); + var span = document.createElement('span'); + span.textContent = bannerText(b, manifest); + div.appendChild(span); + if (b.newest && b.kind !== 'dev') { + var link = document.createElement('a'); + link.href = '/' + loc.lang + '/' + b.newest + '/'; + link.textContent = ' Switch to ' + b.newest + '.'; + div.appendChild(link); + } + var x = document.createElement('button'); + x.textContent = '×'; x.className = 'vyos-banner-dismiss'; + x.setAttribute('aria-label', 'Dismiss'); + x.addEventListener('click', function () { + try { window.localStorage.setItem(key, '1'); } catch (e) { /* ignore */ } + div.remove(); + }); + div.appendChild(x); + document.body.insertBefore(div, document.body.firstChild); + } + + window.VyOSVersionPicker = { + parseLocation: parseLocation, bannerFor: bannerFor, encodePath: encodePath, + targetUrlFor: targetUrlFor, navUrlFor: navUrlFor, langUrlFor: langUrlFor, init: init, + }; + if (typeof document !== 'undefined' && document.addEventListener) + document.addEventListener('DOMContentLoaded', init); +})(window); -- cgit v1.2.3