From 423c65f72cd69a62ec46e91ca32cee5c969105e2 Mon Sep 17 00:00:00 2001 From: Chingis S Date: Wed, 23 Sep 2026 08:32:20 +0400 Subject: [PATCH 1/2] Version documentation stylesheet URLs to bypass stale caches --- 1.0/mkdocs.yml | 2 +- 2.0/mkdocs.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/1.0/mkdocs.yml b/1.0/mkdocs.yml index e2b29804..6ad055d6 100644 --- a/1.0/mkdocs.yml +++ b/1.0/mkdocs.yml @@ -56,7 +56,7 @@ theme: name: Switch to light mode custom_dir: 'theme' -extra_css: ["assets/wodby.css"] +extra_css: ["assets/wodby.css?v=20260923"] extra_javascript: ["assets/intercom.js"] markdown_extensions: diff --git a/2.0/mkdocs.yml b/2.0/mkdocs.yml index 85a19fc5..22ddd039 100644 --- a/2.0/mkdocs.yml +++ b/2.0/mkdocs.yml @@ -58,7 +58,7 @@ theme: custom_dir: 'theme' -extra_css: [ "assets/wodby.css" ] +extra_css: [ "assets/wodby.css?v=20260923" ] extra_javascript: [ "assets/intercom.js" ] markdown_extensions: From 5affb170727a2e23740d0924b50fdf2a6423b3df Mon Sep 17 00:00:00 2001 From: Chingis S Date: Wed, 23 Sep 2026 08:41:55 +0400 Subject: [PATCH 2/2] Generate content-hashed URLs for custom documentation assets --- .github/workflows/agent-docs.yml | 5 ++- .github/workflows/workflow.yml | 2 +- 1.0/mkdocs.yml | 5 ++- 1.0/theme/assets/wodby.css | 8 ++-- 2.0/mkdocs.yml | 3 +- 2.0/theme/assets/wodby.css | 8 ++-- README.md | 7 +++ scripts/test_version_assets.py | 73 ++++++++++++++++++++++++++++++++ scripts/version_assets.py | 40 +++++++++++++++++ 9 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 scripts/test_version_assets.py create mode 100644 scripts/version_assets.py diff --git a/.github/workflows/agent-docs.yml b/.github/workflows/agent-docs.yml index 4fbe251a..fe021d09 100644 --- a/.github/workflows/agent-docs.yml +++ b/.github/workflows/agent-docs.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - '2.0/**' + - '1.0/mkdocs.yml' + - 'scripts/**' - '.github/workflows/agent-docs.yml' workflow_dispatch: @@ -19,8 +21,9 @@ jobs: with: python-version: '3.11' - name: Install documentation tools - run: python -m pip install -r 2.0/requirements-agent-docs.txt + run: python -m pip install mkdocs-material -r 2.0/requirements-agent-docs.txt - name: Check Markdown exports and documentation contracts run: | + python -m unittest discover -s scripts python -m unittest discover -s 2.0/scripts -p 'test_agent_docs.py' python 2.0/scripts/check_agent_contract.py --live diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index ffb5ca43..791b7b82 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -25,7 +25,7 @@ jobs: run: | # The build image enables pip user installs; --target needs that disabled. wodby ci run -e HOME=/home/wodby -i wodby/mkdocs -- \ - sh -c "PIP_USER=false python3 -m pip install --target /tmp/wodby-docs-python -r 2.0/requirements-agent-docs.txt && export PYTHONPATH=/tmp/wodby-docs-python && mkdir sites && cd 1.0 && sh scripts/check-docs.sh -d ../sites/1.0 && cd ../2.0 && python3 -m unittest discover -s scripts -p test_agent_docs.py && python3 scripts/check_agent_contract.py && mkdocs build --strict -d ../sites/2.0" + sh -c "PIP_USER=false python3 -m pip install --target /tmp/wodby-docs-python -r 2.0/requirements-agent-docs.txt && export PYTHONPATH=/tmp/wodby-docs-python && python3 -m unittest discover -s scripts && mkdir sites && cd 1.0 && sh scripts/check-docs.sh -d ../sites/1.0 && cd ../2.0 && python3 -m unittest discover -s scripts -p test_agent_docs.py && python3 scripts/check_agent_contract.py && mkdocs build --strict -d ../sites/2.0" - name: Build image run: wodby ci build nginx -f Dockerfile diff --git a/1.0/mkdocs.yml b/1.0/mkdocs.yml index 6ad055d6..0e653fa2 100644 --- a/1.0/mkdocs.yml +++ b/1.0/mkdocs.yml @@ -56,7 +56,10 @@ theme: name: Switch to light mode custom_dir: 'theme' -extra_css: ["assets/wodby.css?v=20260923"] +hooks: + - ../scripts/version_assets.py + +extra_css: ["assets/wodby.css"] extra_javascript: ["assets/intercom.js"] markdown_extensions: diff --git a/1.0/theme/assets/wodby.css b/1.0/theme/assets/wodby.css index dc9c987b..24ac18a2 100644 --- a/1.0/theme/assets/wodby.css +++ b/1.0/theme/assets/wodby.css @@ -208,13 +208,13 @@ body { -webkit-font-smoothing: antialiased; } } /* The two-tone mark sits on white so its original colors work in either theme. */ -.md-header .md-logo:has(img[src$="/logo.svg"]), -.md-nav--primary .md-nav__title .md-logo:has(img[src$="/logo.svg"]) { +.md-header .md-logo:has(img[src*="/images/logo."]), +.md-nav--primary .md-nav__title .md-logo:has(img[src*="/images/logo."]) { background: #ffffff; border: 1px solid var(--docs-border); } -.md-header .md-logo img[src$="/logo.svg"], -.md-nav--primary .md-nav__title .md-logo img[src$="/logo.svg"] { +.md-header .md-logo img[src*="/images/logo."], +.md-nav--primary .md-nav__title .md-logo img[src*="/images/logo."] { width: 2rem; height: 1.4rem; object-fit: contain; diff --git a/2.0/mkdocs.yml b/2.0/mkdocs.yml index 22ddd039..8f4b0fd7 100644 --- a/2.0/mkdocs.yml +++ b/2.0/mkdocs.yml @@ -58,7 +58,7 @@ theme: custom_dir: 'theme' -extra_css: [ "assets/wodby.css?v=20260923" ] +extra_css: [ "assets/wodby.css" ] extra_javascript: [ "assets/intercom.js" ] markdown_extensions: @@ -184,6 +184,7 @@ plugins: 'services/volumes.md': 'services/configuration.md#volumes' hooks: + - ../scripts/version_assets.py - scripts/include_generated_reference_sitemap.py - scripts/agent_docs.py diff --git a/2.0/theme/assets/wodby.css b/2.0/theme/assets/wodby.css index dc9c987b..24ac18a2 100644 --- a/2.0/theme/assets/wodby.css +++ b/2.0/theme/assets/wodby.css @@ -208,13 +208,13 @@ body { -webkit-font-smoothing: antialiased; } } /* The two-tone mark sits on white so its original colors work in either theme. */ -.md-header .md-logo:has(img[src$="/logo.svg"]), -.md-nav--primary .md-nav__title .md-logo:has(img[src$="/logo.svg"]) { +.md-header .md-logo:has(img[src*="/images/logo."]), +.md-nav--primary .md-nav__title .md-logo:has(img[src*="/images/logo."]) { background: #ffffff; border: 1px solid var(--docs-border); } -.md-header .md-logo img[src$="/logo.svg"], -.md-nav--primary .md-nav__title .md-logo img[src$="/logo.svg"] { +.md-header .md-logo img[src*="/images/logo."], +.md-nav--primary .md-nav__title .md-logo img[src*="/images/logo."] { width: 2rem; height: 1.4rem; object-fit: contain; diff --git a/README.md b/README.md index 7e2d5f00..ae847cd0 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,10 @@ See https://wodby.com/docs Docs built using [mkdocs](http://www.mkdocs.org) with [material theme](https://github.com/squidfunk/mkdocs-material) + +Custom theme CSS, JavaScript, logos, and favicons receive content-hashed filenames +at build time through `scripts/version_assets.py` (MkDocs 1.6 or newer). Edit the +source assets normally; their public URLs change automatically when their contents +change. Material's bundled assets already use versioned filenames. + +Run the asset-versioning checks with `python -m unittest discover -s scripts`. diff --git a/scripts/test_version_assets.py b/scripts/test_version_assets.py new file mode 100644 index 00000000..958dbadf --- /dev/null +++ b/scripts/test_version_assets.py @@ -0,0 +1,73 @@ +"""Verify asset changes invalidate URLs without invalidating unchanged assets.""" + +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +from mkdocs.config import load_config +from mkdocs.structure.files import Files + +import version_assets + + +class AssetVersionTests(unittest.TestCase): + def setUp(self): + self.temp = TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + (self.root / 'docs').mkdir() + self.assets = self.root / 'theme' / 'assets' + self.assets.mkdir(parents=True) + for name, content in [('style.css', 'body { color: blue; }'), + ('app.js', 'console.log("ready");'), + ('logo.svg', '')]: + (self.assets / name).write_text(content) + cfg = self.root / 'mkdocs.yml' + cfg.write_text('''site_name: Test +site_dir: output +theme: + name: material + custom_dir: theme + logo: assets/logo.svg + favicon: assets/logo.svg +extra_css: [assets/style.css, 'https://example.com/external.css'] +extra_javascript: [assets/app.js] +''') + self.config = load_config(str(cfg)) + self.config.plugins._current_plugin = "asset-version-test" + version_assets._source_urls.clear() + + def build_assets(self): + return version_assets.on_files(Files([]), config=self.config) + + def test_local_assets_are_emitted_and_external_urls_preserved(self): + files = self.build_assets() + urls = [self.config.extra_css[0], self.config.extra_javascript[0], + self.config.theme['logo'], self.config.theme['favicon']] + self.assertEqual(len(files), 3) + for url in urls: + self.assertRegex(str(url), r'\.[0-9a-f]{16}\.') + self.assertIsNotNone(files.get_file_from_path(str(url))) + self.assertEqual(self.config.extra_css[1], 'https://example.com/external.css') + self.assertEqual(files.get_file_from_path(urls[0]).content_bytes, + (self.assets / 'style.css').read_bytes()) + + def test_unchanged_rebuild_keeps_urls(self): + self.build_assets() + previous = list(self.config.extra_css) + self.build_assets() + self.assertEqual(previous, self.config.extra_css) + + def test_changed_asset_gets_new_url_on_reused_config(self): + self.build_assets() + css = self.config.extra_css[0] + logo = self.config.theme['logo'] + (self.assets / 'style.css').write_text('body { color: green; }') + files = self.build_assets() + self.assertNotEqual(css, self.config.extra_css[0]) + self.assertEqual(logo, self.config.theme['logo']) + self.assertIsNotNone(files.get_file_from_path(self.config.extra_css[0])) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/version_assets.py b/scripts/version_assets.py new file mode 100644 index 00000000..8cad2fe5 --- /dev/null +++ b/scripts/version_assets.py @@ -0,0 +1,40 @@ +"""Give custom theme assets content-addressed URLs for safe long-lived caching.""" + +from hashlib import sha256 +from pathlib import Path +from urllib.parse import urlsplit + +from mkdocs.structure.files import File + +# MkDocs serve can reuse the mutated config between builds. +_source_urls = {} + + +def on_files(files, *, config): + theme_dir = Path(config.theme.custom_dir) + generated = set() + + def version(url): + original = _source_urls.get(str(url), str(url)) + parsed = urlsplit(original) + if parsed.scheme or parsed.netloc or original.startswith('/'): + return url + source = theme_dir / parsed.path + if not source.is_file(): + return url + content = source.read_bytes() + path = Path(parsed.path) + digest = sha256(content).hexdigest()[:16] + target = path.with_name(f'{path.stem}.{digest}{path.suffix}').as_posix() + if target not in generated: + files.append(File.generated(config, target, content=content)) + generated.add(target) + _source_urls[target] = original + return target + + config.extra_css = [version(url) for url in config.extra_css] + config.extra_javascript = [version(url) for url in config.extra_javascript] + for name in ('logo', 'favicon'): + if config.theme.get(name): + config.theme[name] = version(config.theme[name]) + return files