diff --git a/docs/snippet-var-resolver.md b/docs/snippet-var-resolver.md new file mode 100644 index 0000000..b470bf9 --- /dev/null +++ b/docs/snippet-var-resolver.md @@ -0,0 +1,32 @@ +# Snippet Var Resolver Plugin + +The Snippet Var Resolver plugin resolves `{{ variable }}` placeholders that survive into the rendered HTML of a page after pymdownx.snippets has injected snippet content. + +The problem it solves: mkdocs-macros resolves Jinja2 variables during the `on_page_markdown` event, but pymdownx.snippets injects snippet file content later during markdown-to-HTML conversion — after macros has already run. Any `{{ variable }}` references inside snippet files therefore appear unresolved in the final HTML. + +This plugin runs on the `on_page_content` event (after snippets have been injected) and replaces any remaining `{{ variable }}` patterns using the `include_yaml` files listed in the macros plugin config. + +## 🔹 Usage + +Add the plugin **after `macros`** in your `mkdocs.yml`: + +```yaml +plugins: + - macros: + include_yaml: + - polkadot-docs/variables.yml + - snippet_var_resolver +``` + +No additional configuration is required. The plugin reads its variable sources directly from the macros plugin config. + +## 🔹 Configuration + +This plugin has no configuration options. It self-configures from the surrounding plugin setup. + +## 🔹 Notes + +- Variable lookup supports dotted paths — e.g. `{{ dependencies.zombienet.version }}` resolves into nested YAML structures. +- Unknown placeholders (keys not found in any variable source) are left untouched, so unrelated Jinja2 syntax in the HTML is not affected. +- If multiple `include_yaml` files define the same top-level key, the last file wins (same behaviour as mkdocs-macros). +- The plugin runs on the `on_page_content` event, so it processes the rendered HTML of each page rather than the Markdown source. diff --git a/plugins/snippet_var_resolver/__init__.py b/plugins/snippet_var_resolver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/snippet_var_resolver/plugin.py b/plugins/snippet_var_resolver/plugin.py new file mode 100644 index 0000000..2cab4cd --- /dev/null +++ b/plugins/snippet_var_resolver/plugin.py @@ -0,0 +1,90 @@ +import logging +import re + +import yaml +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.plugins import BasePlugin +from mkdocs.structure.files import Files +from mkdocs.structure.pages import Page + +log = logging.getLogger("mkdocs.plugins.snippet_var_resolver") + +PLACEHOLDER_PATTERN = re.compile(r"{{\s*([A-Za-z0-9_.-]+)\s*}}") + + +def get_value_from_path(data, path): + """Dotted key lookup into a nested dict (e.g. 'dependencies.foo.version').""" + value = data + for key in path.split("."): + if not isinstance(value, dict) or key not in value: + return None + value = value[key] + return value + + +class SnippetVarResolverPlugin(BasePlugin): + config_scheme = () + + def on_config(self, config: MkDocsConfig) -> MkDocsConfig: + self._variables = {} + + # Load variables from macros plugin's include_yaml files + macros = config["plugins"].get("macros") + if macros: + for yaml_path in macros.config.get("include_yaml", []): + self._load_yaml_file(yaml_path, config) + else: + log.debug("[snippet_var_resolver] macros plugin not found, skipping include_yaml") + + if self._variables: + log.debug(f"[snippet_var_resolver] loaded {len(self._variables)} top-level variable keys") + else: + log.warning("[snippet_var_resolver] no variables loaded — {{ }} patterns will not be resolved") + + return config + + def _load_yaml_file(self, yaml_path: str, config: MkDocsConfig) -> None: + from pathlib import Path + + # Resolve relative to project root, then docs_dir, then as an absolute path + candidates = [ + Path(config["docs_dir"]).parent / yaml_path, + Path(config["docs_dir"]) / yaml_path, + Path(yaml_path), + ] + for path in candidates: + if path.exists(): + try: + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + except yaml.YAMLError as exc: + log.warning(f"[snippet_var_resolver] unable to parse {path}: {exc}") + return + if not isinstance(data, dict): + log.warning(f"[snippet_var_resolver] expected a YAML mapping in {path}, got {type(data).__name__} — skipping") + return + self._variables.update(data) + log.debug(f"[snippet_var_resolver] loaded variables from {path}") + return + + log.warning(f"[snippet_var_resolver] variables file not found: {yaml_path}") + + def on_page_content( + self, html: str, page: Page, config: MkDocsConfig, files: Files + ) -> str: + if not self._variables: + return html + + def replacer(match): + key_path = match.group(1) + value = get_value_from_path(self._variables, key_path) + if value is not None: + return str(value) + return match.group(0) + + resolved = PLACEHOLDER_PATTERN.sub(replacer, html) + + if resolved != html: + log.debug(f"[snippet_var_resolver] resolved variables in {page.file.src_path}") + + return resolved diff --git a/pyproject.toml b/pyproject.toml index 35e9c06..60afe84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "papermoon-mkdocs-plugins" -version = "0.1.0a10" +version = "0.1.0a11" description = "A collection of MkDocs plugins" readme = "README.md" requires-python = ">=3.10" @@ -33,6 +33,7 @@ ai_resources_page = "plugins.ai_resources_page.plugin:AiResourcesPagePlugin" ai_page_actions = "plugins.ai_page_actions.plugin:AiPageActionsPlugin" ai_docs = "plugins.ai_docs.plugin:AIDocsPlugin" link_processor = "plugins.link_processor.plugin:LinkProcessorPlugin" +snippet_var_resolver = "plugins.snippet_var_resolver.plugin:SnippetVarResolverPlugin" # Configuration for development tools diff --git a/tests/snippet_var_resolver/__init__.py b/tests/snippet_var_resolver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/snippet_var_resolver/test_snippet_var_resolver.py b/tests/snippet_var_resolver/test_snippet_var_resolver.py new file mode 100644 index 0000000..074926b --- /dev/null +++ b/tests/snippet_var_resolver/test_snippet_var_resolver.py @@ -0,0 +1,195 @@ +from unittest.mock import MagicMock + +import yaml + +from plugins.snippet_var_resolver.plugin import SnippetVarResolverPlugin, get_value_from_path + + +def make_plugin(variables=None): + plugin = SnippetVarResolverPlugin() + plugin.load_config({}) + plugin._variables = variables or {} + return plugin + + +def resolve(html, variables=None): + plugin = make_plugin(variables) + return plugin.on_page_content(html, page=MagicMock(), config=None, files=None) + + + +class TestGetValueFromPath: + def test_top_level_key(self): + assert get_value_from_path({"version": "1.0"}, "version") == "1.0" + + def test_dotted_path(self): + data = {"dependencies": {"foo": {"version": "2.3"}}} + assert get_value_from_path(data, "dependencies.foo.version") == "2.3" + + def test_missing_key_returns_none(self): + assert get_value_from_path({"a": "b"}, "missing") is None + + def test_partially_missing_path_returns_none(self): + data = {"dependencies": {"foo": "bar"}} + assert get_value_from_path(data, "dependencies.foo.version") is None + + def test_empty_path_returns_none(self): + assert get_value_from_path({"a": "b"}, "") is None + + +class TestVariableResolution: + def test_simple_variable_replaced(self): + out = resolve("
Version: {{ version }}
", {"version": "1.2.3"}) + assert out == "Version: 1.2.3
" + + def test_dotted_path_replaced(self): + variables = {"deps": {"zombienet": {"version": "v1.3.0"}}} + out = resolve("{{ deps.zombienet.version }}
", variables) + assert out == "v1.3.0
" + + def test_unknown_variable_left_intact(self): + out = resolve("{{ unknown.key }}
", {"version": "1.0"}) + assert out == "{{ unknown.key }}
" + + def test_multiple_variables_in_one_page(self): + variables = {"name": "Polkadot", "version": "1.0"} + out = resolve("{{ name }} {{ version }}
", variables) + assert out == "Polkadot 1.0
" + + def test_mixed_known_and_unknown(self): + out = resolve("{{ name }} and {{ missing }}
", {"name": "Polkadot"}) + assert out == "Polkadot and {{ missing }}
" + + def test_no_variables_loaded_returns_html_unchanged(self): + html = "{{ version }}
" + out = resolve(html, {}) + assert out == html + + def test_whitespace_variants_in_placeholder(self): + variables = {"version": "1.0"} + assert "1.0" in resolve("{{version}}
", variables) + assert "1.0" in resolve("{{ version }}
", variables) + + def test_variable_in_href(self): + variables = {"repo": {"url": "https://github.com/example/repo"}} + out = resolve('link', variables) + assert 'href="https://github.com/example/repo"' in out + + def test_non_string_value_cast_to_string(self): + out = resolve("{{ count }}
", {"count": 42}) + assert out == "42
" + + def test_no_placeholders_returns_html_unchanged(self): + html = "No variables here.
" + assert resolve(html, {"version": "1.0"}) == html + + +class TestOnConfig: + def test_loads_variables_from_include_yaml(self, tmp_path): + # Plugin resolves yaml paths relative to docs_dir parent, so place + # variables.yml at tmp_path and set docs_dir to tmp_path/docs. + (tmp_path / "variables.yml").write_text(yaml.dump({"version": "3.0", "name": "Test"})) + + plugin = SnippetVarResolverPlugin() + plugin.load_config({}) + config = _build_config(tmp_path, include_yaml=["variables.yml"]) + + plugin.on_config(config) + assert plugin._variables.get("version") == "3.0" + assert plugin._variables.get("name") == "Test" + + def test_merges_multiple_yaml_files(self, tmp_path): + (tmp_path / "a.yml").write_text(yaml.dump({"key_a": "val_a"})) + (tmp_path / "b.yml").write_text(yaml.dump({"key_b": "val_b"})) + + plugin = SnippetVarResolverPlugin() + plugin.load_config({}) + config = _build_config(tmp_path, include_yaml=["a.yml", "b.yml"]) + + plugin.on_config(config) + assert plugin._variables.get("key_a") == "val_a" + assert plugin._variables.get("key_b") == "val_b" + + def test_malformed_yaml_does_not_raise(self, tmp_path): + (tmp_path / "bad.yml").write_text("key: [unclosed") + + plugin = SnippetVarResolverPlugin() + plugin.load_config({}) + config = _build_config(tmp_path, include_yaml=["bad.yml"]) + + plugin.on_config(config) # should not raise + assert plugin._variables == {} + + def test_non_mapping_yaml_does_not_raise(self, tmp_path): + (tmp_path / "list.yml").write_text(yaml.dump(["item1", "item2"])) + + plugin = SnippetVarResolverPlugin() + plugin.load_config({}) + config = _build_config(tmp_path, include_yaml=["list.yml"]) + + plugin.on_config(config) # should not raise + assert plugin._variables == {} + + def test_later_yaml_file_wins_on_conflict(self, tmp_path): + (tmp_path / "a.yml").write_text(yaml.dump({"version": "1.0"})) + (tmp_path / "b.yml").write_text(yaml.dump({"version": "2.0"})) + + plugin = SnippetVarResolverPlugin() + plugin.load_config({}) + config = _build_config(tmp_path, include_yaml=["a.yml", "b.yml"]) + + plugin.on_config(config) + assert plugin._variables.get("version") == "2.0" + + def test_missing_yaml_file_does_not_raise(self, tmp_path): + plugin = SnippetVarResolverPlugin() + plugin.load_config({}) + config = _build_config(tmp_path, include_yaml=["nonexistent.yml"]) + + plugin.on_config(config) # should not raise + assert plugin._variables == {} + + def test_no_macros_plugin_does_not_raise(self, tmp_path): + plugin = SnippetVarResolverPlugin() + plugin.load_config({}) + + config = MagicMock() + config.__getitem__ = lambda self, key: { + "plugins": {}, + "extra": {}, + "docs_dir": str(tmp_path / "docs"), + }[key] + config.get = lambda key, default=None: { + "plugins": {}, + "extra": {}, + "docs_dir": str(tmp_path / "docs"), + }.get(key, default) + + plugin.on_config(config) # should not raise + assert plugin._variables == {} + + +# --- helpers --- + +def _make_macros(include_yaml=None): + macros = MagicMock() + macros.config = {"include_yaml": include_yaml or []} + return macros + + +def _build_config(tmp_path, include_yaml=None, extra=None): + macros = _make_macros(include_yaml) + docs_dir = str(tmp_path / "docs") + + config = MagicMock() + config.__getitem__ = lambda self, key: { + "plugins": {"macros": macros}, + "extra": extra or {}, + "docs_dir": docs_dir, + }[key] + config.get = lambda key, default=None: { + "plugins": {"macros": macros}, + "extra": extra or {}, + "docs_dir": docs_dir, + }.get(key, default) + return config