diff --git a/macro_hooks.py b/macro_hooks.py index 364ceefd2..5c231ad01 100644 --- a/macro_hooks.py +++ b/macro_hooks.py @@ -5,6 +5,7 @@ If this is confusing, ask Cal to explain. """ +import importlib.util import os import json @@ -12,6 +13,25 @@ tag_index_path = os.getenv("TAG_INDEX_PATH", "docs/assets/tag-index.json") +def _load_mkdocs_hooks(): + """Load mkdocs_hooks.py by file path rather than `import mkdocs_hooks`. + + mkdocs and mkdocs-macros load their own hook module through different + mechanisms (mkdocs briefly patches sys.path and restores it; mkdocs-macros + execs this file directly by path), so a plain cross-file import can't + reliably resolve `mkdocs_hooks` regardless of how *this* file was loaded. + Resolving relative to our own (always-correct) __file__ sidesteps that. + """ + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mkdocs_hooks.py") + spec = importlib.util.spec_from_file_location("mkdocs_hooks", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +configure_safe_includes = _load_mkdocs_hooks().configure_safe_includes + + class CaseInsensitiveDict(dict): """Dict wrapper allowing `applications[app_name]` lookups regardless of case.""" @@ -59,3 +79,10 @@ def pages_with_tag(tag): {"title": e["title"], "path": os.path.relpath(e["path"], current_dir)} for e in entries ] + + +def on_pre_page_macros(env): + # env.env (the actual Jinja Environment) doesn't exist yet at define_env() + # time - on_config() creates it afterwards - so patch it here instead. + # configure_safe_includes() is idempotent, so re-running per page is cheap. + configure_safe_includes(env.env) diff --git a/mkdocs_hooks.py b/mkdocs_hooks.py index dd04856f0..55cc3c425 100644 --- a/mkdocs_hooks.py +++ b/mkdocs_hooks.py @@ -13,9 +13,80 @@ import re import yaml +from jinja2.compiler import CodeGenerator +from jinja2.exceptions import TemplateNotFound module_list_path = os.getenv("MODULE_LIST_PATH", "docs/assets/module-list.json") + +class SafeIncludeCodeGenerator(CodeGenerator): + """Makes every `{% include %}` in this environment render as nothing + instead of raising, if the included template errors while rendering. + + Partials driven by module-list.json data (versions, network licences, + ...) can fail on one app's unexpected/missing data. Without this, that + error propagates and takes out the *whole* page (mkdocs-macros' error + block) or the *whole* build (the supported-apps index renders every app + inline via the theme's own Jinja env, outside mkdocs-macros' handling). + + `{% extends %}` and `{% import %}` are untouched (separate visit_* + methods) - only `{% include %}` gets this treatment. + + Set as `environment.code_generator_class` (an official Jinja extension + point - see Environment.code_generator_class), it replaces the include's + normal streaming codegen with a call to `environment.safe_include()`, + which renders the target as one buffered string (so a mid-render failure + can't leak partial output) and swallows/logs any exception. + """ + + def visit_Include(self, node, frame): + self.writeline("yield environment.safe_include(", node) + self.visit(node.template, frame) + if node.with_context: + self.write(f", {{**context.get_all(), **{self.dump_local_context(frame)}}}") + else: + self.write(", {}") + self.write(f", {node.ignore_missing!r})") + + +def make_safe_include(jinja_env): + """Build the `environment.safe_include()` called by the codegen above. + + A closure (not a method) bound to one specific environment via + `env.safe_include = make_safe_include(env)`, since generated template + code looks it up as a plain attribute (`environment.safe_include(...)`) + rather than through the descriptor protocol. + """ + def safe_include(name, context_dict, ignore_missing=False): + try: + template = (jinja_env.select_template(name) + if isinstance(name, (list, tuple)) + else jinja_env.get_template(name)) + except TemplateNotFound: + if ignore_missing: + return "" + raise + try: + return template.render(context_dict) + except Exception as e: + print(f"::WARNING file={name},title=include_failed,col=0,endColumn=0,line=0::{e}") + return "" + return safe_include + + +def configure_safe_includes(jinja_env): + """Make every `{% include %}` rendered by `jinja_env` fail soft. + + Idempotent - safe to call more than once on the same environment (e.g. + macro_hooks.py's on_pre_page_macros(), which fires per page). + """ + if getattr(jinja_env, "_safe_includes_configured", False): + return + jinja_env.code_generator_class = SafeIncludeCodeGenerator + jinja_env.safe_include = make_safe_include(jinja_env) + jinja_env._safe_includes_configured = True + + _FRONT_MATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL) @@ -90,6 +161,7 @@ def on_env(env, config, files, **kwargs): for app in applications.values() for domain in app.get("domains", []) }) + configure_safe_includes(env) def lint(*args, **kwargs):