summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-09-25 17:11:19 +0200
committerGitHub <noreply@github.com>2026-09-25 16:11:19 +0100
commita783e56b774795c1f4bd8a6088dfcce96a307c5f (patch)
treedcd0c88ff02969351840c8222ea97c72d9258e18 /docs
parent4c8d1d1a0f23e2106a97f4b672607121ff03a091 (diff)
downloadvyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.tar.gz
vyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.zip
ci: port the Cloudflare Workers docs pipeline to circinus (slug 1.5) (#2208)
* 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 -- <paths>`, 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)
Diffstat (limited to 'docs')
-rw-r--r--docs/_static/css/version-picker.css11
-rw-r--r--docs/_static/js/pagefind-wrapper.js69
-rw-r--r--docs/_static/js/version-picker.js193
-rw-r--r--docs/_templates/breadcrumbs.html5
-rw-r--r--docs/_templates/searchbox.html13
-rw-r--r--docs/conf.py48
6 files changed, 339 insertions, 0 deletions
diff --git a/docs/_static/css/version-picker.css b/docs/_static/css/version-picker.css
new file mode 100644
index 00000000..16ce16c3
--- /dev/null
+++ b/docs/_static/css/version-picker.css
@@ -0,0 +1,11 @@
+.vyos-version-picker-item { list-style: none; }
+#vyos-version-picker { display: flex; align-items: center; gap: .5em; padding: .4em 0; font-size: .9em; }
+#vyos-version-picker select { max-width: 14em; padding: .15em .3em; }
+#vyos-version-picker select:focus { outline: 2px solid #ffae12; outline-offset: 1px; }
+.vyos-pdf-link { font-weight: 600; }
+.vyos-version-banner { position: relative; padding: .6em 2.2em .6em 1em; font-size: .95em; line-height: 1.4; }
+.vyos-banner-dev { background: #e7f2fa; color: #2a6496; }
+.vyos-banner-newer-lts { background: #fff6e5; color: #6b5900; }
+.vyos-banner-eol { background: #fdecea; color: #8a1f11; }
+.vyos-version-banner a { text-decoration: underline; color: inherit; font-weight: 600; }
+.vyos-banner-dismiss { position: absolute; right: .5em; top: .35em; background: none; border: 0; font-size: 1.2em; cursor: pointer; color: inherit; }
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-<n>/ 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-<n>/ 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 /<lang>/<slug>/ 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);
diff --git a/docs/_templates/breadcrumbs.html b/docs/_templates/breadcrumbs.html
new file mode 100644
index 00000000..1df3c859
--- /dev/null
+++ b/docs/_templates/breadcrumbs.html
@@ -0,0 +1,5 @@
+{%- extends "sphinx_rtd_theme/breadcrumbs.html" %}
+{% block breadcrumbs_aside %}
+<li class="vyos-version-picker-item"><div id="vyos-version-picker" aria-label="Documentation version"></div></li>
+{{ super() }}
+{% endblock %}
diff --git a/docs/_templates/searchbox.html b/docs/_templates/searchbox.html
new file mode 100644
index 00000000..5fdd378f
--- /dev/null
+++ b/docs/_templates/searchbox.html
@@ -0,0 +1,13 @@
+{#- Full override (sphinx_rtd_theme/layout.html does a plain {% include "searchbox.html" %},
+ not a named block, so there is nothing to {% extends %} + {% block %} here). Only mounts
+ the Pagefind UI on CF-Workers builds (conf.py sets html_context['vyos_cf_build'] from
+ DOCS_VERSION_SLUG) โ€” ReadTheDocs runs plain Sphinx with no Pagefind step, so the mount's
+ assets would 404 there. On RTD (and any other non-CF build) fall through to the theme's
+ stock server-side search form via Sphinx's "!" bang-prefix (forces resolution from the
+ theme, bypassing this override, per sphinx/jinja2glue.py). #}
+{% if vyos_cf_build %}
+<div id="vyos-search" role="search"></div>
+<noscript>{% include "!searchbox.html" %}</noscript>
+{% else %}
+{% include "!searchbox.html" %}
+{% endif %}
diff --git a/docs/conf.py b/docs/conf.py
index ece118e4..36075621 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -46,6 +46,18 @@ extensions = ['sphinx.ext.intersphinx',
'sphinx.ext.todo',
'sphinx.ext.ifconfig',
'sphinx.ext.graphviz',
+ # LaTeX-only: converts image formats the LaTeX/PDF builder can't
+ # embed natively (webp, svg, ...) to PNG at build time. The
+ # LaTeX builder's supported_image_types is ['application/pdf',
+ # 'image/png', 'image/jpeg'] โ€” without this, unsupported
+ # images (nearly all of ours are .webp) are silently dropped
+ # from the PDF output. No effect on the HTML builder, which
+ # supports webp/svg natively in the browser; imgconverter
+ # only fires post-transforms when the active builder's
+ # supported_image_types doesn't already cover the source
+ # format. See `image_converter` below + docker/im-convert.sh
+ # for the conversion command this depends on.
+ 'sphinx.ext.imgconverter',
'notfound.extension',
'autosectionlabel',
'myst_parser',
@@ -55,6 +67,20 @@ extensions = ['sphinx.ext.intersphinx',
'sphinx_sitemap',
]
+# sphinx.ext.imgconverter: use a thin wrapper (docker/im-convert.sh, installed
+# on PATH as `im-convert`) instead of ImageMagick's `convert` directly.
+# Debian's `imagemagick` package is built --without-rsvg, so its built-in SVG
+# coder (a minimal libxml2-based renderer, not a librsvg wrapper) can't
+# rasterize SVGs that embed a base64 raster <image> element โ€” common in
+# diagrams exported from draw.io/diagrams.net โ€” and fails with "unable to
+# open image `image/png;base64,...'". The wrapper routes .svg sources to
+# `rsvg-convert` (from librsvg2-bin) directly and everything else (webp,
+# gif, pdf, ...) through ImageMagick's `convert` as usual. If `im-convert`
+# isn't on PATH (e.g. a build environment other than docker/Dockerfile),
+# imgconverter's own `is_available()` check logs a warning and skips
+# conversion rather than failing the build.
+image_converter = 'im-convert'
+
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@@ -122,6 +148,27 @@ html_static_path = ['_static']
html_extra_path = ['_html_extra']
+# Version picker + status banner + language scaffold (docs/_static/js/version-picker.js,
+# docs/_static/css/version-picker.css). Appended rather than assigned in case a later
+# addition to this file defines these lists first. Registered unconditionally: it degrades
+# silently on ReadTheDocs (fetch of /versions.json fails there, so nothing renders).
+# globals().get(...) (not a bare `html_js_files` reference guarded by `'html_js_files' in
+# dir()`) avoids a static-analysis F821 (possibly-undefined name) while keeping the same
+# runtime behavior: append to an existing list if one was already defined, else start fresh.
+html_js_files = [*globals().get('html_js_files', []), 'js/version-picker.js']
+html_css_files = [*globals().get('html_css_files', []), 'css/version-picker.css']
+
+# CF-Workers builds inject DOCS_VERSION_SLUG (docs-build.yml); ReadTheDocs builds
+# (until sunset) run plain Sphinx with no Pagefind step, so the Pagefind wrapper
+# script + the searchbox.html override (docs/_templates/searchbox.html) must only
+# activate for CF builds โ€” otherwise RTD visitors hit a 404ing search mount.
+_vyos_cf_build = bool(os.environ.get('DOCS_VERSION_SLUG'))
+if _vyos_cf_build:
+ html_js_files = [*html_js_files, 'js/pagefind-wrapper.js']
+
+# circinus keeps building on ReadTheDocs until RTD sunset and its CF slug is also
+# `1.5`, so the baseurl is identical under both builders โ€” the rolling branch's
+# DOCS_VERSION_SLUG/READTHEDOCS_VERSION resolution block is deliberately not ported.
html_baseurl = 'https://docs.vyos.io/en/1.5/'
_rtd_version_type = os.environ.get('READTHEDOCS_VERSION_TYPE', '')
@@ -139,6 +186,7 @@ html_context = {
'conf_py_path': '/docs/',
'gtm_id': os.environ.get('GTM_ID', ''),
'cookiebot_id': os.environ.get('COOKIEBOT_ID', ''),
+ 'vyos_cf_build': _vyos_cf_build,
}
# sphinx-sitemap: baseurl already includes /en/1.5/, so skip lang+version