diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 190e84c..e719d8e 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()