From b8c5d89777f37500968fa104f221ebd6c1b092d7 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Fri, 8 May 2026 14:42:40 +0200 Subject: [PATCH 1/2] fix(theme): detect Material's slate scheme as dark mode --- .github/workflows/check.yml | 9 + .../overrides/javascripts/bifrost-theme.js | 97 +++++++ .../overrides/main.html | 92 +------ src/intility_bifrost_mkdocs/plugin.py | 3 +- tests/javascripts/bifrost-theme.test.js | 255 ++++++++++++++++++ tests/test_plugin.py | 41 ++- 6 files changed, 401 insertions(+), 96 deletions(-) create mode 100644 src/intility_bifrost_mkdocs/overrides/javascripts/bifrost-theme.js create mode 100644 tests/javascripts/bifrost-theme.test.js diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 190e84c..85af846 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -58,3 +58,12 @@ jobs: - name: Run tests run: uv run pytest + + js-test: + name: JS Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Bifrost theme JS tests + run: node --test 'tests/javascripts/*.test.js' diff --git a/src/intility_bifrost_mkdocs/overrides/javascripts/bifrost-theme.js b/src/intility_bifrost_mkdocs/overrides/javascripts/bifrost-theme.js new file mode 100644 index 0000000..19c3325 --- /dev/null +++ b/src/intility_bifrost_mkdocs/overrides/javascripts/bifrost-theme.js @@ -0,0 +1,97 @@ +/* + * Bifrost theme sync. + * + * Mirrors Material for MkDocs' palette state onto the Bifrost CSS framework: + * data-md-color-scheme -> .bf-lightmode / .bf-darkmode on + * data-md-color-primary -> .bf-theme-{teal,purple,pink,yellow} + * + * Also inserts an optional version badge into the header when a + * tag is present. + * + * Exposes pure functions on `module.exports` for unit testing under + * Node's built-in test runner. + */ +(function (global) { + var BIFROST_THEMES = ['teal', 'purple', 'pink', 'yellow']; + var DARK_SCHEMES = ['dark', 'slate']; + var DEFAULT_THEME = 'teal'; + + function syncBifrostTheme(html, body) { + if (!html || !body) return; + + var scheme = body.getAttribute('data-md-color-scheme'); + var primary = body.getAttribute('data-md-color-primary'); + + if (DARK_SCHEMES.indexOf(scheme) !== -1) { + html.classList.add('bf-darkmode'); + html.classList.remove('bf-lightmode'); + } else { + html.classList.add('bf-lightmode'); + html.classList.remove('bf-darkmode'); + } + + BIFROST_THEMES.forEach(function (theme) { + html.classList.remove('bf-theme-' + theme); + }); + var resolved = BIFROST_THEMES.indexOf(primary) !== -1 ? primary : DEFAULT_THEME; + html.classList.add('bf-theme-' + resolved); + } + + function readVersion(doc) { + if (!doc || !doc.querySelector) return null; + var meta = doc.querySelector('meta[name="bifrost-version"]'); + if (!meta) return null; + var content = meta.getAttribute('content'); + return content && content.length > 0 ? content : null; + } + + function insertVersionBadge(doc, headerTopic, version) { + if (!doc || !headerTopic || !version) return null; + if (headerTopic.querySelector && headerTopic.querySelector('.bf-header-version')) { + return null; + } + var badge = doc.createElement('span'); + badge.className = 'bf-badge bf-badge-pill bfc-theme-fade-bg bf-header-version'; + badge.textContent = 'v' + version; + headerTopic.appendChild(badge); + return badge; + } + + function init() { + var html = document.documentElement; + var body = document.body; + syncBifrostTheme(html, body); + + var version = readVersion(document); + if (version) { + insertVersionBadge(document, document.querySelector('.md-header__topic'), version); + } + + var observer = new MutationObserver(function () { + syncBifrostTheme(html, body); + }); + observer.observe(body, { + attributes: true, + attributeFilter: ['data-md-color-scheme', 'data-md-color-primary'], + }); + } + + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { + syncBifrostTheme: syncBifrostTheme, + readVersion: readVersion, + insertVersionBadge: insertVersionBadge, + BIFROST_THEMES: BIFROST_THEMES, + DARK_SCHEMES: DARK_SCHEMES, + DEFAULT_THEME: DEFAULT_THEME, + }; + } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/intility_bifrost_mkdocs/overrides/main.html b/src/intility_bifrost_mkdocs/overrides/main.html index f00d6e1..c9f12d1 100644 --- a/src/intility_bifrost_mkdocs/overrides/main.html +++ b/src/intility_bifrost_mkdocs/overrides/main.html @@ -4,7 +4,8 @@ Bifrost MkDocs Theme A custom theme that extends Material for MkDocs while implementing - Intility's Bifrost design system. + Intility's Bifrost design system. Theme-sync behaviour lives in + `assets/javascripts/bifrost-theme.js`, injected via the plugin. #} {% block htmltitle %} @@ -17,90 +18,7 @@ {% endblock %} {% block extrahead %} - {# Apply Bifrost classes based on Material's palette configuration #} - + {%- if config.extra.version -%} + + {%- endif -%} {% endblock %} - -{% block scripts %} - {{ super() }} -{% endblock %} \ No newline at end of file diff --git a/src/intility_bifrost_mkdocs/plugin.py b/src/intility_bifrost_mkdocs/plugin.py index 193de9e..1c5814a 100644 --- a/src/intility_bifrost_mkdocs/plugin.py +++ b/src/intility_bifrost_mkdocs/plugin.py @@ -85,9 +85,10 @@ } # --------------------------------------------------------------------------- -# Default extra JavaScript (MathJax). +# Default extra JavaScript. # --------------------------------------------------------------------------- DEFAULT_EXTRA_JS: list[str] = [ + "javascripts/bifrost-theme.js", "javascripts/mathjax.js", "https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js", ] diff --git a/tests/javascripts/bifrost-theme.test.js b/tests/javascripts/bifrost-theme.test.js new file mode 100644 index 0000000..62d26bd --- /dev/null +++ b/tests/javascripts/bifrost-theme.test.js @@ -0,0 +1,255 @@ +/* + * Unit tests for src/intility_bifrost_mkdocs/overrides/javascripts/bifrost-theme.js + * + * Run with: + * node --test tests/javascripts/ + * + * No external dependencies. Uses Node's built-in `node:test` runner and a + * tiny hand-rolled DOM stub so the JS module can be loaded as CommonJS. + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const MODULE_PATH = path.resolve( + __dirname, + '..', + '..', + 'src', + 'intility_bifrost_mkdocs', + 'overrides', + 'javascripts', + 'bifrost-theme.js', +); + +const { + syncBifrostTheme, + readVersion, + insertVersionBadge, + BIFROST_THEMES, +} = require(MODULE_PATH); + +function makeClassList() { + const set = new Set(); + return { + add(c) { set.add(c); }, + remove(c) { set.delete(c); }, + contains(c) { return set.has(c); }, + toArray() { return [...set]; }, + }; +} + +function makeHtml(initialClasses = []) { + const classList = makeClassList(); + initialClasses.forEach((c) => classList.add(c)); + return { classList }; +} + +function makeBody(scheme, primary) { + return { + getAttribute(name) { + if (name === 'data-md-color-scheme') return scheme; + if (name === 'data-md-color-primary') return primary; + return null; + }, + }; +} + +// --------------------------------------------------------------------------- +// syncBifrostTheme — light/dark mode mapping +// --------------------------------------------------------------------------- + +test('slate scheme maps to bf-darkmode (Material standard)', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('slate', 'teal')); + assert.ok(html.classList.contains('bf-darkmode')); + assert.ok(!html.classList.contains('bf-lightmode')); +}); + +test('dark scheme maps to bf-darkmode (plugin documented)', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('dark', 'teal')); + assert.ok(html.classList.contains('bf-darkmode')); + assert.ok(!html.classList.contains('bf-lightmode')); +}); + +test('default scheme maps to bf-lightmode (Material standard)', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('default', 'teal')); + assert.ok(html.classList.contains('bf-lightmode')); + assert.ok(!html.classList.contains('bf-darkmode')); +}); + +test('light scheme maps to bf-lightmode (plugin documented)', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('light', 'teal')); + assert.ok(html.classList.contains('bf-lightmode')); + assert.ok(!html.classList.contains('bf-darkmode')); +}); + +test('missing scheme falls back to bf-lightmode', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody(null, 'teal')); + assert.ok(html.classList.contains('bf-lightmode')); + assert.ok(!html.classList.contains('bf-darkmode')); +}); + +test('unknown scheme value falls back to bf-lightmode', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('chartreuse', 'teal')); + assert.ok(html.classList.contains('bf-lightmode')); + assert.ok(!html.classList.contains('bf-darkmode')); +}); + +test('switching from dark back to light removes bf-darkmode', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('slate', 'teal')); + assert.ok(html.classList.contains('bf-darkmode')); + + syncBifrostTheme(html, makeBody('default', 'teal')); + assert.ok(html.classList.contains('bf-lightmode')); + assert.ok(!html.classList.contains('bf-darkmode')); +}); + +// --------------------------------------------------------------------------- +// syncBifrostTheme — primary/theme mapping +// --------------------------------------------------------------------------- + +BIFROST_THEMES.forEach((primary) => { + test(`primary=${primary} applies bf-theme-${primary}`, () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('default', primary)); + assert.ok(html.classList.contains('bf-theme-' + primary)); + }); +}); + +test('missing primary defaults to bf-theme-teal', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('default', null)); + assert.ok(html.classList.contains('bf-theme-teal')); +}); + +test('unknown primary defaults to bf-theme-teal', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('default', 'mauve')); + assert.ok(html.classList.contains('bf-theme-teal')); +}); + +test('switching theme removes the previous bf-theme-* class', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('default', 'purple')); + assert.ok(html.classList.contains('bf-theme-purple')); + + syncBifrostTheme(html, makeBody('default', 'pink')); + assert.ok(html.classList.contains('bf-theme-pink')); + assert.ok(!html.classList.contains('bf-theme-purple')); +}); + +test('only one bf-theme-* class is set at a time', () => { + const html = makeHtml(); + syncBifrostTheme(html, makeBody('default', 'yellow')); + const themeClasses = html.classList.toArray().filter((c) => c.startsWith('bf-theme-')); + assert.equal(themeClasses.length, 1); + assert.equal(themeClasses[0], 'bf-theme-yellow'); +}); + +// --------------------------------------------------------------------------- +// syncBifrostTheme — guards +// --------------------------------------------------------------------------- + +test('returns silently when html or body is missing', () => { + // Should not throw. + syncBifrostTheme(null, null); + syncBifrostTheme(makeHtml(), null); + syncBifrostTheme(null, makeBody('slate', 'teal')); +}); + +// --------------------------------------------------------------------------- +// readVersion / insertVersionBadge +// --------------------------------------------------------------------------- + +function makeDoc({ version = null, hasBadge = false } = {}) { + const calls = []; + return { + querySelector(selector) { + calls.push(selector); + if (selector === 'meta[name="bifrost-version"]') { + if (version === null) return null; + return { + getAttribute(name) { + return name === 'content' ? version : null; + }, + }; + } + return null; + }, + createElement(_tag) { + return { + className: '', + textContent: '', + }; + }, + _calls: calls, + }; +} + +function makeHeaderTopic({ alreadyBadged = false } = {}) { + const appended = []; + return { + querySelector(selector) { + if (selector === '.bf-header-version' && alreadyBadged) { + return { existing: true }; + } + return null; + }, + appendChild(child) { + appended.push(child); + return child; + }, + appended, + }; +} + +test('readVersion returns content when meta tag is present', () => { + const doc = makeDoc({ version: '1.2.3' }); + assert.equal(readVersion(doc), '1.2.3'); +}); + +test('readVersion returns null when meta tag is absent', () => { + const doc = makeDoc({ version: null }); + assert.equal(readVersion(doc), null); +}); + +test('readVersion returns null for empty content', () => { + const doc = { + querySelector: () => ({ getAttribute: () => '' }), + }; + assert.equal(readVersion(doc), null); +}); + +test('insertVersionBadge appends a badge with the version text', () => { + const doc = makeDoc(); + const topic = makeHeaderTopic(); + const badge = insertVersionBadge(doc, topic, '0.7.0'); + assert.ok(badge); + assert.equal(badge.textContent, 'v0.7.0'); + assert.match(badge.className, /bf-header-version/); + assert.equal(topic.appended.length, 1); +}); + +test('insertVersionBadge skips when a badge is already present', () => { + const doc = makeDoc(); + const topic = makeHeaderTopic({ alreadyBadged: true }); + const badge = insertVersionBadge(doc, topic, '0.7.0'); + assert.equal(badge, null); + assert.equal(topic.appended.length, 0); +}); + +test('insertVersionBadge is a no-op without version, doc, or topic', () => { + const doc = makeDoc(); + const topic = makeHeaderTopic(); + assert.equal(insertVersionBadge(null, topic, '1.0.0'), null); + assert.equal(insertVersionBadge(doc, null, '1.0.0'), null); + assert.equal(insertVersionBadge(doc, topic, null), null); + assert.equal(topic.appended.length, 0); +}); diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 806d3e1..d2c890f 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -5,16 +5,17 @@ from mkdocs.config.defaults import MkDocsConfig from intility_bifrost_mkdocs.plugin import ( + BIFROST_FONT_CODE, + BIFROST_FONT_TEXT, DEFAULT_ADMONITION_ICONS, DEFAULT_EXTENSIONS, - DEFAULT_EXTRA_JS, DEFAULT_FEATURES, - BIFROST_FONT_CODE, - BIFROST_FONT_TEXT, IntilityBifrostPlugin, ) -OVERRIDES_DIR = Path(__file__).parent.parent / "src" / "intility_bifrost_mkdocs" / "overrides" +OVERRIDES_DIR = ( + Path(__file__).parent.parent / "src" / "intility_bifrost_mkdocs" / "overrides" +) def _minimal_config() -> MkDocsConfig: @@ -65,7 +66,9 @@ def test_overrides_directory_exists(): assert (OVERRIDES_DIR / "assets" / "stylesheets" / "extra.css").is_file() assert (OVERRIDES_DIR / "assets" / "stylesheets" / "bifrost.css").is_file() assert (OVERRIDES_DIR / "assets" / "fonts" / "satoshi-variable.woff2").is_file() - assert (OVERRIDES_DIR / "assets" / "fonts" / "satoshi-variable-italic.woff2").is_file() + assert ( + OVERRIDES_DIR / "assets" / "fonts" / "satoshi-variable-italic.woff2" + ).is_file() def test_plugin_preserves_existing_extra_css(): @@ -81,7 +84,9 @@ def test_plugin_preserves_existing_extra_css(): assert result["extra_css"][0] == "assets/stylesheets/extra.css" assert "custom/user.css" in result["extra_css"] assert "custom/other.css" in result["extra_css"] - assert result["extra_css"].index("assets/stylesheets/extra.css") < result["extra_css"].index("custom/user.css") + assert result["extra_css"].index("assets/stylesheets/extra.css") < result[ + "extra_css" + ].index("custom/user.css") # --------------------------------------------------------------------------- @@ -97,7 +102,9 @@ def test_default_extensions_injected(): result = plugin.on_config(config) for ext in DEFAULT_EXTENSIONS: - assert ext in result.markdown_extensions, f"{ext} missing from markdown_extensions" + assert ext in result.markdown_extensions, ( + f"{ext} missing from markdown_extensions" + ) def test_default_extension_configs_injected(): @@ -109,7 +116,9 @@ def test_default_extension_configs_injected(): assert result.mdx_configs.get("toc", {}).get("permalink") is True assert result.mdx_configs.get("pymdownx.arithmatex", {}).get("generic") is True - assert result.mdx_configs.get("pymdownx.highlight", {}).get("anchor_linenums") is True + assert ( + result.mdx_configs.get("pymdownx.highlight", {}).get("anchor_linenums") is True + ) def test_user_extension_config_preserved(): @@ -246,6 +255,22 @@ def test_mathjax_js_injected(): assert "https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js" in paths +def test_bifrost_theme_js_injected(): + """The Bifrost theme-sync script should be added to extra_javascript.""" + plugin = IntilityBifrostPlugin() + config = _minimal_config() + + result = plugin.on_config(config) + + paths = [str(entry) for entry in result.extra_javascript] + assert "javascripts/bifrost-theme.js" in paths + + +def test_bifrost_theme_js_exists_in_overrides(): + """The bifrost-theme.js file should ship in the overrides directory.""" + assert (OVERRIDES_DIR / "javascripts" / "bifrost-theme.js").is_file() + + def test_existing_extra_javascript_preserved(): """User-provided extra_javascript entries should not be removed.""" plugin = IntilityBifrostPlugin() From 07de02707799f0b1f541d846744d5364eb295731 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Fri, 8 May 2026 14:47:35 +0200 Subject: [PATCH 2/2] ci: let bash expand the JS test glob --- .github/workflows/check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 85af846..e719d8e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -66,4 +66,4 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Bifrost theme JS tests - run: node --test 'tests/javascripts/*.test.js' + run: node --test tests/javascripts/*.test.js