From adfc9c61d87f0142b10bd5ee37143d2d0c494b0b Mon Sep 17 00:00:00 2001 From: Henrik Holmboe Date: Sun, 19 Jul 2026 04:35:26 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Make=20a=20document's=20o?= =?UTF-8?q?wn=20front=20matter=20available=20in=20env.metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sphinx's MetadataCollector only populates env.metadata on the doctree-read event, i.e. after the whole document has been parsed, whereas MyST substitutions are evaluated during the parse. A document could therefore read other documents' front matter via `env.metadata["other"]["key"]`, but its own entry was still empty, so `{{ env.metadata[env.docname]["key"] }}` failed. The renderer now eagerly seeds env.metadata when the front matter token is rendered (the first token of the document). Values are read back off the already-built field list, so they are identical to what the collector stores later: bibliographic field names are canonicalized via the language module, dedication/abstract are skipped (docutils moves them outside the docinfo, so the collector never stores them), and tocdepth is cast to int. Closes #947 Co-Authored-By: Claude Fable 5 --- docs/syntax/optional.md | 26 +++++++++++ myst_parser/mdit_to_docutils/base.py | 45 ++++++++++++++++++- .../sourcedirs/frontmatter_metadata/conf.py | 3 ++ .../sourcedirs/frontmatter_metadata/index.md | 21 +++++++++ .../sourcedirs/frontmatter_metadata/other.md | 9 ++++ tests/test_sphinx/test_sphinx_builds.py | 36 +++++++++++++++ .../test_frontmatter_metadata.other.xml | 10 +++++ .../test_frontmatter_metadata.xml | 15 +++++++ 8 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 tests/test_sphinx/sourcedirs/frontmatter_metadata/conf.py create mode 100644 tests/test_sphinx/sourcedirs/frontmatter_metadata/index.md create mode 100644 tests/test_sphinx/sourcedirs/frontmatter_metadata/other.md create mode 100644 tests/test_sphinx/test_sphinx_builds/test_frontmatter_metadata.other.xml create mode 100644 tests/test_sphinx/test_sphinx_builds/test_frontmatter_metadata.xml diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index 55bb9301..28fb4715 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -403,6 +403,32 @@ Therefore you can do things like: - {{ "a" + "b" }} ::: +This includes `env.metadata`, which holds the [front matter](syntax/frontmatter) of each document, +keyed by docname, and so allows a document to refer to its own metadata: + +````md +--- +last_review_date: 1970-09-08 +--- + +Last reviewed on {{ env.metadata[env.docname]["last_review_date"] }} +```` + +```{versionadded} 5.2.0 +Previously, only *other* documents' metadata was available; a document could not read its own. +``` + +Note that these values are always strings (non-scalars are JSON encoded), since this is how Sphinx +stores them, so use e.g. string methods or Jinja2 filters rather than date methods. + +:::{warning} +Referring to *another* document's metadata, e.g. `env.metadata["other-doc"]["key"]`, is fragile: +the value is only present if that document has already been read in this build, +which depends on build order, and is not the case at all under parallel reads. +It is also not registered as a dependency, so the referring document will not be rebuilt +when the other document's front matter changes. +::: + You can also change the delimiter if necessary, for example setting in the `conf.py`: ```python diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index 68cfae72..af25dce0 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -1325,10 +1325,50 @@ def render_front_matter(self, token: SyntaxTreeNode) -> None: fields, language_code=self.document.settings.language_code ) self.current_node.append(field_list) + self._add_fm_env_metadata(field_list, self.document.settings.language_code) if data.get("title") and self.md_config.title_to_header: self.nested_render_text(f"# {data['title']}", 0) + def _add_fm_env_metadata( + self, field_list: nodes.field_list, language_code: str + ) -> None: + """Eagerly add the front matter to the sphinx ``env.metadata``. + + Sphinx's ``MetadataCollector`` only populates ``env.metadata`` on the + ``doctree-read`` event, i.e. after the whole document has been parsed. + Adding the metadata here as well makes it available *during* the parse, + so that a document can reference its own front matter, + e.g. ``{{ env.metadata[env.docname]["key"] }}`` in a substitution. + + The values are read back off the rendered fields, + so that they mirror what the collector will store later, + and thus do not change between build phases. + + :param field_list: the field list rendered from the front matter. + :param language_code: used to resolve localised bibliographic field names. + """ + if self.sphinx_env is None: + return + bibliofields = get_language(language_code).bibliographic_fields + meta = self.sphinx_env.metadata[self.sphinx_env.docname] + for field in field_list: + name: str = field[0].astext() + value: Any = field[1].astext() + # the `DocInfo` transform converts bibliographic fields to dedicated + # nodes, which the collector then keys by the canonical (English) name + name = bibliofields.get(name.lower(), name) + if name in ("dedication", "abstract"): + # these are placed outside the `docinfo`, so are never collected + continue + if name == "tocdepth": + # as per MetadataCollector + try: + value = int(value) + except ValueError: + value = 0 + meta[name] = value + def dict_to_fm_field_list( self, data: dict[str, Any], language_code: str, line: int = 0 ) -> nodes.field_list: @@ -1362,7 +1402,10 @@ def dict_to_fm_field_list( by the `DoctreeReadEvent` transform (priority 880), calling `MetadataCollector.process_doc`. In this case keys and values will be converted to strings and stored in - `app.env.metadata[app.env.docname]` + `app.env.metadata[app.env.docname]`. + Note that this only happens once the whole document has been parsed, + so `_add_fm_env_metadata` additionally seeds the same values during the + render, to make them readable from within the document itself. See https://www.sphinx-doc.org/en/master/usage/restructuredtext/field-lists.html diff --git a/tests/test_sphinx/sourcedirs/frontmatter_metadata/conf.py b/tests/test_sphinx/sourcedirs/frontmatter_metadata/conf.py new file mode 100644 index 00000000..f1ec2155 --- /dev/null +++ b/tests/test_sphinx/sourcedirs/frontmatter_metadata/conf.py @@ -0,0 +1,3 @@ +extensions = ["myst_parser"] +exclude_patterns = ["_build"] +myst_enable_extensions = ["substitution"] diff --git a/tests/test_sphinx/sourcedirs/frontmatter_metadata/index.md b/tests/test_sphinx/sourcedirs/frontmatter_metadata/index.md new file mode 100644 index 00000000..b48ae127 --- /dev/null +++ b/tests/test_sphinx/sourcedirs/frontmatter_metadata/index.md @@ -0,0 +1,21 @@ +--- +tocdepth: 2 +last_review_date: 1970-09-08 +reviewers: + - alice + - bob +links: + home: https://example.com +--- + +# Index + +Own metadata: {{ env.metadata[env.docname]["last_review_date"] }} + +Own list: {{ env.metadata[env.docname]["reviewers"] }} + +Own dict: {{ env.metadata[env.docname]["links"] }} + +```{toctree} +other.md +``` diff --git a/tests/test_sphinx/sourcedirs/frontmatter_metadata/other.md b/tests/test_sphinx/sourcedirs/frontmatter_metadata/other.md new file mode 100644 index 00000000..eaf75639 --- /dev/null +++ b/tests/test_sphinx/sourcedirs/frontmatter_metadata/other.md @@ -0,0 +1,9 @@ +--- +last_review_date: 2037-09-08 +--- + +# Other + +Own metadata: {{ env.metadata[env.docname]["last_review_date"] }} + +Metadata of an already-read document: {{ env.metadata["index"]["last_review_date"] }} diff --git a/tests/test_sphinx/test_sphinx_builds.py b/tests/test_sphinx/test_sphinx_builds.py index 333b24dc..0c13c689 100644 --- a/tests/test_sphinx/test_sphinx_builds.py +++ b/tests/test_sphinx/test_sphinx_builds.py @@ -451,6 +451,42 @@ def test_substitutions( get_sphinx_app_output(app, filename="index.html", regress_html=True) +@pytest.mark.sphinx( + buildername="html", + srcdir=os.path.join(SOURCE_DIR, "frontmatter_metadata"), + freshenv=True, +) +def test_frontmatter_metadata( + app, + status, + warning, + get_sphinx_app_doctree, + file_regression, + normalize_doctree_xml, +): + """Test that a document can access its own front matter via ``env.metadata``.""" + app.build() + assert "build succeeded" in status.getvalue() + assert warning.getvalue().strip() == "" + + # values should be the same strings that sphinx's MetadataCollector stores, + # except for ``tocdepth``, which it converts to an integer + ignore = {"myst_slugs", "wordcount"} # added separately by the renderer + metadata = {k: v for k, v in app.env.metadata["index"].items() if k not in ignore} + assert metadata == { + "tocdepth": 2, + "last_review_date": "1970-09-08", + "reviewers": '["alice", "bob"]', + "links": '{"home": "https://example.com"}', + } + + get_sphinx_app_doctree(app, docname="index", regress=True) + file_regression.check( + normalize_doctree_xml(get_sphinx_app_doctree(app, docname="other").pformat()), + extension=".other.xml", + ) + + @pytest.mark.sphinx( buildername="html", srcdir=os.path.join(SOURCE_DIR, "substitutions_missing"), diff --git a/tests/test_sphinx/test_sphinx_builds/test_frontmatter_metadata.other.xml b/tests/test_sphinx/test_sphinx_builds/test_frontmatter_metadata.other.xml new file mode 100644 index 00000000..c1a0fcad --- /dev/null +++ b/tests/test_sphinx/test_sphinx_builds/test_frontmatter_metadata.other.xml @@ -0,0 +1,10 @@ + +
+ + Other + <paragraph> + Own metadata: + 2037-09-08 + <paragraph> + Metadata of an already-read document: + 1970-09-08 diff --git a/tests/test_sphinx/test_sphinx_builds/test_frontmatter_metadata.xml b/tests/test_sphinx/test_sphinx_builds/test_frontmatter_metadata.xml new file mode 100644 index 00000000..938ebb96 --- /dev/null +++ b/tests/test_sphinx/test_sphinx_builds/test_frontmatter_metadata.xml @@ -0,0 +1,15 @@ +<document source="index.md"> + <section ids="index" names="index"> + <title> + Index + <paragraph> + Own metadata: + 1970-09-08 + <paragraph> + Own list: + [“alice”, “bob”] + <paragraph> + Own dict: + {“home”: “https://example.com”} + <compound classes="toctree-wrapper"> + <toctree caption="True" entries="(None,\ 'other')" glob="False" hidden="False" includefiles="other" includehidden="False" maxdepth="-1" numbered="0" parent="index" rawentries="" titlesonly="False">