markdown-macros-extension 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BarCar (https://github.com/barcar)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: markdown-macros-extension
3
+ Version: 0.1.0
4
+ Summary: Python-Markdown extension: MkDocs-Macros–style Jinja2 templating (variables, macros, filters) with YAML front matter for Zensical and MkDocs
5
+ Author: BarCar
6
+ License: MIT
7
+ Project-URL: Documentation, https://github.com/barcar/markdown-macros#readme
8
+ Project-URL: Repository, https://github.com/barcar/markdown-macros
9
+ Keywords: markdown,frontmatter,yaml,zensical,mkdocs,macros
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Text Processing :: Markup
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: markdown>=3.4
25
+ Requires-Dist: pyyaml>=6.0
26
+ Requires-Dist: jinja2>=3.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7; extra == "dev"
29
+ Requires-Dist: pytest-cov; extra == "dev"
30
+ Provides-Extra: docs
31
+ Requires-Dist: zensical; extra == "docs"
32
+ Dynamic: license-file
33
+
34
+ # Markdown Macros
35
+
36
+ [![Deploy docs to GitHub Pages](https://github.com/barcar/markdown-macros/actions/workflows/pages.yml/badge.svg)](https://github.com/barcar/markdown-macros/actions/workflows/pages.yml)
37
+
38
+ **Author:** [BarCar](https://github.com/barcar) · **Repository:** [github.com/barcar/markdown-macros](https://github.com/barcar/markdown-macros)
39
+
40
+ A **Python-Markdown extension** that brings [MkDocs Macros](https://mkdocs-macros-plugin.readthedocs.io/)–style **Jinja2 templating** to any Markdown pipeline: variables (config + YAML front matter), **macros**, and **filters**. Works with [Zensical](https://zensical.org), MkDocs, or plain Python—without the MkDocs plugin.
41
+
42
+ ## Features
43
+
44
+ - **Variables** from config, YAML front matter, or `include_yaml`
45
+ - **Macros and filters** via `define_env(env)` in a Python module (same API as MkDocs Macros)
46
+ - **YAML front matter** parsed and exposed as `md.Meta` / `md.front_matter`
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install markdown-macros-extension
52
+ ```
53
+
54
+ Requires: `markdown>=3.4`, `pyyaml>=6.0`, `jinja2>=3.0`.
55
+
56
+ ## Quick example
57
+
58
+ ```python
59
+ import markdown
60
+ from markdown_macros import MacrosExtension
61
+
62
+ text = """---
63
+ title: My Page
64
+ ---
65
+
66
+ # {{ title }}
67
+
68
+ The answer is {{ 6 * 7 }}.
69
+ """
70
+ md = markdown.Markdown(extensions=[MacrosExtension()])
71
+ html = md.convert(text) # title and 42 rendered
72
+ ```
73
+
74
+ In Zensical or MkDocs, add `markdown_macros` to your Markdown extensions and optionally set `module_name`, `variables`, `include_yaml`, etc. See the [documentation](https://barcar.github.io/markdown-macros/) for configuration, usage, and the full API.
75
+
76
+ **Compatibility:** API-aligned with the [MkDocs Macros plugin](https://mkdocs-macros-plugin.readthedocs.io/) (variables, macros, filters, `define_env`); see [Compatibility](https://barcar.github.io/markdown-macros/compatibility/) in the docs.
77
+
78
+ ## Documentation
79
+
80
+ **Online:** [Documentation](https://barcar.github.io/markdown-macros/) (GitHub Pages).
81
+
82
+ To build the docs locally:
83
+
84
+ ```bash
85
+ pip install -e ".[docs]"
86
+ zensical serve
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,58 @@
1
+ # Markdown Macros
2
+
3
+ [![Deploy docs to GitHub Pages](https://github.com/barcar/markdown-macros/actions/workflows/pages.yml/badge.svg)](https://github.com/barcar/markdown-macros/actions/workflows/pages.yml)
4
+
5
+ **Author:** [BarCar](https://github.com/barcar) · **Repository:** [github.com/barcar/markdown-macros](https://github.com/barcar/markdown-macros)
6
+
7
+ A **Python-Markdown extension** that brings [MkDocs Macros](https://mkdocs-macros-plugin.readthedocs.io/)–style **Jinja2 templating** to any Markdown pipeline: variables (config + YAML front matter), **macros**, and **filters**. Works with [Zensical](https://zensical.org), MkDocs, or plain Python—without the MkDocs plugin.
8
+
9
+ ## Features
10
+
11
+ - **Variables** from config, YAML front matter, or `include_yaml`
12
+ - **Macros and filters** via `define_env(env)` in a Python module (same API as MkDocs Macros)
13
+ - **YAML front matter** parsed and exposed as `md.Meta` / `md.front_matter`
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install markdown-macros-extension
19
+ ```
20
+
21
+ Requires: `markdown>=3.4`, `pyyaml>=6.0`, `jinja2>=3.0`.
22
+
23
+ ## Quick example
24
+
25
+ ```python
26
+ import markdown
27
+ from markdown_macros import MacrosExtension
28
+
29
+ text = """---
30
+ title: My Page
31
+ ---
32
+
33
+ # {{ title }}
34
+
35
+ The answer is {{ 6 * 7 }}.
36
+ """
37
+ md = markdown.Markdown(extensions=[MacrosExtension()])
38
+ html = md.convert(text) # title and 42 rendered
39
+ ```
40
+
41
+ In Zensical or MkDocs, add `markdown_macros` to your Markdown extensions and optionally set `module_name`, `variables`, `include_yaml`, etc. See the [documentation](https://barcar.github.io/markdown-macros/) for configuration, usage, and the full API.
42
+
43
+ **Compatibility:** API-aligned with the [MkDocs Macros plugin](https://mkdocs-macros-plugin.readthedocs.io/) (variables, macros, filters, `define_env`); see [Compatibility](https://barcar.github.io/markdown-macros/compatibility/) in the docs.
44
+
45
+ ## Documentation
46
+
47
+ **Online:** [Documentation](https://barcar.github.io/markdown-macros/) (GitHub Pages).
48
+
49
+ To build the docs locally:
50
+
51
+ ```bash
52
+ pip install -e ".[docs]"
53
+ zensical serve
54
+ ```
55
+
56
+ ## License
57
+
58
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ """
2
+ Markdown Macros: MkDocs-Macros–style extension for Python-Markdown.
3
+
4
+ Jinja2 templating with variables (config + YAML front matter), macros, and
5
+ filters. Parses --- YAML --- front matter and exposes md.Meta / md.front_matter.
6
+ Use define_env(env) in a module for macros/filters/variables.
7
+ """
8
+
9
+ from markdown_macros.front_matter import FrontMatterExtension
10
+ from markdown_macros.macros import MacrosExtension, make_extension
11
+
12
+ # Python-Markdown discovers extensions via makeExtension (camelCase)
13
+ makeExtension = make_extension
14
+
15
+ __all__ = ["FrontMatterExtension", "MacrosExtension", "make_extension", "makeExtension"]
@@ -0,0 +1,106 @@
1
+ """
2
+ YAML Front Matter preprocessor for Python-Markdown.
3
+
4
+ Strips the leading --- YAML --- block from the source and stores parsed data
5
+ in md.Meta (Python-Markdown style: lowercase keys, list-of-strings values)
6
+ and md.front_matter (full parsed dict for nested structures).
7
+
8
+ Compatible with Zensical and MkDocs Macros: runs only at the Markdown layer;
9
+ no Jinja2 or plugin logic. When MkDocs Macros is used, it can consume
10
+ page-level metadata; this extension ensures front matter is available as md.Meta
11
+ for the rest of the pipeline.
12
+ """
13
+
14
+ import re
15
+ from markdown import Extension
16
+ from markdown.preprocessors import Preprocessor
17
+
18
+ try:
19
+ import yaml
20
+ except ImportError:
21
+ yaml = None
22
+
23
+ # Match --- at start of document, then content until next --- (YAML style only for now)
24
+ FRONT_MATTER_RE = re.compile(
25
+ r"^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n",
26
+ re.MULTILINE,
27
+ )
28
+
29
+
30
+ def _meta_from_dict(data):
31
+ """Convert parsed YAML dict to Python-Markdown Meta format: lowercase keys, list values."""
32
+ meta = {}
33
+ if not isinstance(data, dict):
34
+ return meta
35
+ for key, value in data.items():
36
+ k = key.lower().strip()
37
+ if isinstance(value, list):
38
+ meta[k] = [str(v) for v in value]
39
+ elif value is None:
40
+ meta[k] = [""]
41
+ else:
42
+ meta[k] = [str(value)]
43
+ return meta
44
+
45
+
46
+ class FrontMatterPreprocessor(Preprocessor):
47
+ """Preprocessor that strips YAML front matter and stores it on the Markdown instance."""
48
+
49
+ def __init__(self, md, config):
50
+ super().__init__(md)
51
+ self.config = config
52
+
53
+ def run(self, lines):
54
+ text = "\n".join(lines)
55
+ match = FRONT_MATTER_RE.match(text)
56
+ if not match:
57
+ # Ensure Meta and front_matter exist for downstream (e.g. themes, MkDocs)
58
+ if not hasattr(self.md, "Meta") or self.md.Meta is None:
59
+ self.md.Meta = {}
60
+ if not hasattr(self.md, "front_matter") or self.md.front_matter is None:
61
+ self.md.front_matter = {}
62
+ return lines
63
+
64
+ raw_block = match.group(1).strip()
65
+ if yaml is not None:
66
+ try:
67
+ data = yaml.safe_load(raw_block)
68
+ if data is None:
69
+ data = {}
70
+ if not isinstance(data, dict):
71
+ data = {"content": data}
72
+ except Exception:
73
+ data = {}
74
+ else:
75
+ data = {}
76
+
77
+ # Expose for Python-Markdown and downstream (e.g. MkDocs page.meta)
78
+ self.md.Meta = getattr(self.md, "Meta", None) or {}
79
+ self.md.Meta.update(_meta_from_dict(data))
80
+ # Full structure for themes/plugins that expect nested data
81
+ self.md.front_matter = data
82
+
83
+ # Return remaining markdown (without the front matter block)
84
+ rest = text[match.end() :]
85
+ return rest.split("\n")
86
+
87
+
88
+ class FrontMatterExtension(Extension):
89
+ """Python-Markdown extension that parses YAML front matter and exposes it as md.Meta and md.front_matter."""
90
+
91
+ def __init__(self, **kwargs):
92
+ # No config options; declare empty config so base Extension.setConfig behaves correctly.
93
+ self.config = {}
94
+ super().__init__(**kwargs)
95
+
96
+ def extendMarkdown(self, md):
97
+ md.registerExtension(self)
98
+ md.preprocessors.register(
99
+ FrontMatterPreprocessor(md, self.getConfigs()),
100
+ "front_matter",
101
+ priority=25, # Run early, before most other preprocessors (higher = first)
102
+ )
103
+
104
+
105
+ def make_extension(**kwargs):
106
+ return FrontMatterExtension(**kwargs)
@@ -0,0 +1,323 @@
1
+ """
2
+ MkDocs-Macros–style extension for Python-Markdown.
3
+
4
+ Treats the document as a Jinja2 template: parses YAML front matter for
5
+ page-level variables, merges with config variables and optional module
6
+ (macros/filters/variables via define_env(env)), then renders the body.
7
+ Exposes md.Meta and md.front_matter for downstream use.
8
+ """
9
+
10
+ import importlib.util
11
+ import logging
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from markdown import Extension
16
+ from markdown.preprocessors import Preprocessor
17
+
18
+ try:
19
+ import jinja2
20
+ except ImportError:
21
+ jinja2 = None
22
+
23
+ try:
24
+ import yaml
25
+ except ImportError:
26
+ yaml = None
27
+
28
+ logger = logging.getLogger("markdown_macros")
29
+
30
+ FRONT_MATTER_RE = re.compile(
31
+ r"^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n",
32
+ re.MULTILINE,
33
+ )
34
+
35
+
36
+ def _meta_from_dict(data):
37
+ """Convert dict to Python-Markdown Meta: lowercase keys, list values."""
38
+ meta = {}
39
+ if not isinstance(data, dict):
40
+ return meta
41
+ for key, value in data.items():
42
+ k = key.lower().strip()
43
+ if isinstance(value, list):
44
+ meta[k] = [str(v) for v in value]
45
+ elif value is None:
46
+ meta[k] = [""]
47
+ else:
48
+ meta[k] = [str(value)]
49
+ return meta
50
+
51
+
52
+ class MacroEnv:
53
+ """Minimal env object for define_env(env) compatibility with MkDocs Macros."""
54
+
55
+ def __init__(self):
56
+ self.variables = {}
57
+ self._macros = {}
58
+ self._filters = {}
59
+
60
+ def macro(self, fn=None, name=None):
61
+ """Register a macro. Use as @env.macro or env.macro(func) or env.macro(func, 'name')."""
62
+ if fn is None:
63
+ return lambda f: self.macro(f, name)
64
+ self._macros[name or fn.__name__] = fn
65
+ return fn
66
+
67
+ def filter(self, fn=None, name=None):
68
+ """Register a filter. Use as @env.filter or env.filter(func)."""
69
+ if fn is None:
70
+ return lambda f: self.filter(f, name)
71
+ self._filters[name or fn.__name__] = fn
72
+ return fn
73
+
74
+
75
+ def _load_module(module_name, project_root=None):
76
+ """Load a module by name (e.g. 'main') and return (variables, macros, filters) from define_env."""
77
+ root = Path(project_root or ".").resolve()
78
+ for candidate in [root / f"{module_name}.py", root / module_name / "__init__.py"]:
79
+ if candidate.exists():
80
+ spec = importlib.util.spec_from_file_location(module_name, candidate)
81
+ if spec and spec.loader:
82
+ mod = importlib.util.module_from_spec(spec)
83
+ spec.loader.exec_module(mod)
84
+ if hasattr(mod, "define_env"):
85
+ env = MacroEnv()
86
+ mod.define_env(env)
87
+ return env.variables, env._macros, env._filters
88
+ break
89
+ try:
90
+ mod = __import__(module_name)
91
+ if hasattr(mod, "define_env"):
92
+ env = MacroEnv()
93
+ mod.define_env(env)
94
+ return env.variables, env._macros, env._filters
95
+ except ImportError:
96
+ pass
97
+ return {}, {}, {}
98
+
99
+
100
+ def _load_pluglets(module_names):
101
+ """Load pluglet modules by package name; return merged (variables, macros, filters)."""
102
+ all_vars = {}
103
+ all_macros = {}
104
+ all_filters = {}
105
+ for name in module_names or []:
106
+ name = (name or "").strip()
107
+ if not name:
108
+ continue
109
+ try:
110
+ mod = importlib.import_module(name)
111
+ if hasattr(mod, "define_env"):
112
+ env = MacroEnv()
113
+ mod.define_env(env)
114
+ all_vars.update(env.variables)
115
+ all_macros.update(env._macros)
116
+ all_filters.update(env._filters)
117
+ except ImportError as e:
118
+ logger.warning("markdown_macros: could not load pluglet %r: %s", name, e)
119
+ return all_vars, all_macros, all_filters
120
+
121
+
122
+ def _load_one_yaml(path, project_root=None):
123
+ """Load a single YAML file; return dict or None."""
124
+ if not yaml:
125
+ return None
126
+ root = Path(project_root or ".").resolve()
127
+ p = root / path if not Path(path).is_absolute() else Path(path)
128
+ if not p.exists():
129
+ return None
130
+ try:
131
+ with open(p, encoding="utf-8") as f:
132
+ data = yaml.safe_load(f)
133
+ return data if isinstance(data, dict) else None
134
+ except Exception:
135
+ return None
136
+
137
+
138
+ def _merge_include_yaml(include_yaml, project_root, variables):
139
+ """
140
+ Merge include_yaml into variables.
141
+ include_yaml can be: list of paths (flat merge) or dict of key -> path (merge under key).
142
+ """
143
+ if not include_yaml:
144
+ return
145
+ if isinstance(include_yaml, dict):
146
+ for key, path in include_yaml.items():
147
+ data = _load_one_yaml(path, project_root)
148
+ if data is not None:
149
+ variables[key] = data
150
+ else:
151
+ for path in include_yaml:
152
+ data = _load_one_yaml(path, project_root)
153
+ if isinstance(data, dict):
154
+ variables.update(data)
155
+
156
+
157
+ class MacrosPreprocessor(Preprocessor):
158
+ """Parse front matter, build Jinja2 context, render body."""
159
+
160
+ def __init__(self, md, config):
161
+ super().__init__(md)
162
+ self.config = config
163
+
164
+ def run(self, lines):
165
+ text = "\n".join(lines)
166
+ project_root = self.config.get("project_root") or "."
167
+ verbose = self.config.get("verbose", False)
168
+
169
+ # 1) Parse and strip front matter
170
+ match = FRONT_MATTER_RE.match(text)
171
+ if match:
172
+ raw_block = match.group(1).strip()
173
+ if yaml:
174
+ try:
175
+ data = yaml.safe_load(raw_block)
176
+ if data is None:
177
+ data = {}
178
+ if not isinstance(data, dict):
179
+ data = {"content": data}
180
+ except Exception:
181
+ data = {}
182
+ else:
183
+ data = {}
184
+ self.md.Meta = getattr(self.md, "Meta", None) or {}
185
+ self.md.Meta.update(_meta_from_dict(data))
186
+ self.md.front_matter = data
187
+ body = text[match.end() :]
188
+ else:
189
+ self.md.Meta = getattr(self.md, "Meta", None) or {}
190
+ if not hasattr(self.md, "front_matter") or self.md.front_matter is None:
191
+ self.md.front_matter = {}
192
+ body = text
193
+
194
+ # render_by_default: if False, only render when front matter has render_macros: true
195
+ render_by_default = self.config.get("render_by_default", True)
196
+ page_render_macros = self.md.front_matter.get("render_macros")
197
+ if not render_by_default and not page_render_macros:
198
+ if verbose:
199
+ logger.debug("markdown_macros: skipping Jinja2 (render_by_default=False, no render_macros)")
200
+ return body.split("\n")
201
+
202
+ # 2) Build context: config variables + include_yaml + module + pluglets + front matter
203
+ variables = dict(self.config.get("variables") or {})
204
+ _merge_include_yaml(
205
+ self.config.get("include_yaml"),
206
+ project_root,
207
+ variables,
208
+ )
209
+ module_name = (self.config.get("module_name") or "").strip()
210
+ if module_name:
211
+ if verbose:
212
+ logger.debug("markdown_macros: loading module %r", module_name)
213
+ mod_vars, macros, filters = _load_module(module_name, project_root)
214
+ variables.update(mod_vars)
215
+ variables.update(macros)
216
+ else:
217
+ macros = {}
218
+ filters = {}
219
+ # Pluglets (preinstalled modules)
220
+ pluglet_names = self.config.get("modules") or []
221
+ if pluglet_names:
222
+ if verbose:
223
+ logger.debug("markdown_macros: loading pluglets %s", pluglet_names)
224
+ pv, pm, pf = _load_pluglets(pluglet_names)
225
+ variables.update(pv)
226
+ if not module_name:
227
+ macros = {}
228
+ filters = {}
229
+ macros.update(pm)
230
+ filters.update(pf)
231
+ if match:
232
+ variables.update(self.md.front_matter)
233
+
234
+ if not jinja2:
235
+ return body.split("\n")
236
+
237
+ # 3) Build Jinja2 environment
238
+ include_dir = self.config.get("include_dir") or ""
239
+ include_dir_path = (Path(project_root) / include_dir).resolve() if include_dir else None
240
+ env_kw = {
241
+ "block_start_string": self.config.get("j2_block_start_string") or "{%",
242
+ "block_end_string": self.config.get("j2_block_end_string") or "%}",
243
+ "variable_start_string": self.config.get("j2_variable_start_string") or "{{",
244
+ "variable_end_string": self.config.get("j2_variable_end_string") or "}}",
245
+ }
246
+ comment_start = self.config.get("j2_comment_start_string")
247
+ comment_end = self.config.get("j2_comment_end_string")
248
+ if comment_start is not None:
249
+ env_kw["comment_start_string"] = comment_start
250
+ if comment_end is not None:
251
+ env_kw["comment_end_string"] = comment_end
252
+ on_undefined = self.config.get("on_undefined", "keep")
253
+ if on_undefined == "strict":
254
+ env_kw["undefined"] = jinja2.StrictUndefined
255
+ j2_extensions = self.config.get("j2_extensions") or []
256
+ if j2_extensions:
257
+ env_kw["extensions"] = list(j2_extensions)
258
+ if include_dir_path and include_dir_path.exists():
259
+ env_kw["loader"] = jinja2.FileSystemLoader(str(include_dir_path))
260
+ env = jinja2.Environment(**env_kw)
261
+ env.filters.update(filters)
262
+
263
+ # MkDocs Macros compatibility: expand Jinja2 in the YAML "title" field (plugin exception since 1.0.2)
264
+ title_val = variables.get("title")
265
+ if isinstance(title_val, str) and ("{{" in title_val or "{%" in title_val):
266
+ try:
267
+ title_template = env.from_string(title_val)
268
+ variables["title"] = title_template.render(**variables)
269
+ self.md.front_matter["title"] = variables["title"]
270
+ self.md.Meta["title"] = [variables["title"]]
271
+ except Exception:
272
+ pass
273
+
274
+ try:
275
+ if verbose:
276
+ logger.debug("markdown_macros: rendering template")
277
+ template = env.from_string(body)
278
+ rendered = template.render(**variables)
279
+ except Exception as e:
280
+ if self.config.get("on_error_fail", False):
281
+ raise
282
+ if verbose:
283
+ logger.debug("markdown_macros: render error (swallowing): %s", e)
284
+ rendered = body
285
+ return rendered.split("\n")
286
+
287
+
288
+ class MacrosExtension(Extension):
289
+ """Python-Markdown extension: Jinja2 templating with variables, macros, and filters (MkDocs-Macros style)."""
290
+
291
+ def __init__(self, **kwargs):
292
+ self.config = {
293
+ "variables": [{}, "Global variables merged into Jinja2 context"],
294
+ "module_name": ["", "Python module name (e.g. main) with define_env(env)"],
295
+ "modules": [[], "List of pluglet package names (e.g. mkdocs_macros_plugin.include)"],
296
+ "include_yaml": [[], "Paths to YAML files (list) or key: path (dict) to merge into variables"],
297
+ "include_dir": ["", "Directory for {% include 'file' %} (relative to project_root)"],
298
+ "project_root": [".", "Project root for resolving module, YAML, and include_dir paths"],
299
+ "render_by_default": [True, "If False, only render when front matter has render_macros: true"],
300
+ "on_error_fail": [False, "If True, raise on Jinja2 error instead of returning unchanged body"],
301
+ "on_undefined": ["keep", "Undefined vars: 'keep' (leave/empty) or 'strict' (raise)"],
302
+ "verbose": [False, "Log debug messages"],
303
+ "j2_block_start_string": ["{%", "Jinja2 block start"],
304
+ "j2_block_end_string": ["%}", "Jinja2 block end"],
305
+ "j2_variable_start_string": ["{{", "Jinja2 variable start"],
306
+ "j2_variable_end_string": ["}}", "Jinja2 variable end"],
307
+ "j2_comment_start_string": [None, "Jinja2 comment start (default {#)"],
308
+ "j2_comment_end_string": [None, "Jinja2 comment end (default #})"],
309
+ "j2_extensions": [[], "List of Jinja2 extension names or classes"],
310
+ }
311
+ super().__init__(**kwargs)
312
+
313
+ def extendMarkdown(self, md):
314
+ md.registerExtension(self)
315
+ md.preprocessors.register(
316
+ MacrosPreprocessor(md, self.getConfigs()),
317
+ "macros",
318
+ priority=20,
319
+ )
320
+
321
+
322
+ def make_extension(**kwargs):
323
+ return MacrosExtension(**kwargs)
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: markdown-macros-extension
3
+ Version: 0.1.0
4
+ Summary: Python-Markdown extension: MkDocs-Macros–style Jinja2 templating (variables, macros, filters) with YAML front matter for Zensical and MkDocs
5
+ Author: BarCar
6
+ License: MIT
7
+ Project-URL: Documentation, https://github.com/barcar/markdown-macros#readme
8
+ Project-URL: Repository, https://github.com/barcar/markdown-macros
9
+ Keywords: markdown,frontmatter,yaml,zensical,mkdocs,macros
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Text Processing :: Markup
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: markdown>=3.4
25
+ Requires-Dist: pyyaml>=6.0
26
+ Requires-Dist: jinja2>=3.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7; extra == "dev"
29
+ Requires-Dist: pytest-cov; extra == "dev"
30
+ Provides-Extra: docs
31
+ Requires-Dist: zensical; extra == "docs"
32
+ Dynamic: license-file
33
+
34
+ # Markdown Macros
35
+
36
+ [![Deploy docs to GitHub Pages](https://github.com/barcar/markdown-macros/actions/workflows/pages.yml/badge.svg)](https://github.com/barcar/markdown-macros/actions/workflows/pages.yml)
37
+
38
+ **Author:** [BarCar](https://github.com/barcar) · **Repository:** [github.com/barcar/markdown-macros](https://github.com/barcar/markdown-macros)
39
+
40
+ A **Python-Markdown extension** that brings [MkDocs Macros](https://mkdocs-macros-plugin.readthedocs.io/)–style **Jinja2 templating** to any Markdown pipeline: variables (config + YAML front matter), **macros**, and **filters**. Works with [Zensical](https://zensical.org), MkDocs, or plain Python—without the MkDocs plugin.
41
+
42
+ ## Features
43
+
44
+ - **Variables** from config, YAML front matter, or `include_yaml`
45
+ - **Macros and filters** via `define_env(env)` in a Python module (same API as MkDocs Macros)
46
+ - **YAML front matter** parsed and exposed as `md.Meta` / `md.front_matter`
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install markdown-macros-extension
52
+ ```
53
+
54
+ Requires: `markdown>=3.4`, `pyyaml>=6.0`, `jinja2>=3.0`.
55
+
56
+ ## Quick example
57
+
58
+ ```python
59
+ import markdown
60
+ from markdown_macros import MacrosExtension
61
+
62
+ text = """---
63
+ title: My Page
64
+ ---
65
+
66
+ # {{ title }}
67
+
68
+ The answer is {{ 6 * 7 }}.
69
+ """
70
+ md = markdown.Markdown(extensions=[MacrosExtension()])
71
+ html = md.convert(text) # title and 42 rendered
72
+ ```
73
+
74
+ In Zensical or MkDocs, add `markdown_macros` to your Markdown extensions and optionally set `module_name`, `variables`, `include_yaml`, etc. See the [documentation](https://barcar.github.io/markdown-macros/) for configuration, usage, and the full API.
75
+
76
+ **Compatibility:** API-aligned with the [MkDocs Macros plugin](https://mkdocs-macros-plugin.readthedocs.io/) (variables, macros, filters, `define_env`); see [Compatibility](https://barcar.github.io/markdown-macros/compatibility/) in the docs.
77
+
78
+ ## Documentation
79
+
80
+ **Online:** [Documentation](https://barcar.github.io/markdown-macros/) (GitHub Pages).
81
+
82
+ To build the docs locally:
83
+
84
+ ```bash
85
+ pip install -e ".[docs]"
86
+ zensical serve
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ markdown_macros/__init__.py
5
+ markdown_macros/front_matter.py
6
+ markdown_macros/macros.py
7
+ markdown_macros_extension.egg-info/PKG-INFO
8
+ markdown_macros_extension.egg-info/SOURCES.txt
9
+ markdown_macros_extension.egg-info/dependency_links.txt
10
+ markdown_macros_extension.egg-info/entry_points.txt
11
+ markdown_macros_extension.egg-info/requires.txt
12
+ markdown_macros_extension.egg-info/top_level.txt
13
+ tests/test_front_matter.py
14
+ tests/test_macros.py
@@ -0,0 +1,2 @@
1
+ [markdown.extensions]
2
+ markdown_macros = markdown_macros:MacrosExtension
@@ -0,0 +1,10 @@
1
+ markdown>=3.4
2
+ pyyaml>=6.0
3
+ jinja2>=3.0
4
+
5
+ [dev]
6
+ pytest>=7
7
+ pytest-cov
8
+
9
+ [docs]
10
+ zensical
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "markdown-macros-extension"
7
+ version = "0.1.0"
8
+ description = "Python-Markdown extension: MkDocs-Macros–style Jinja2 templating (variables, macros, filters) with YAML front matter for Zensical and MkDocs"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ authors = [{ name = "BarCar" }]
13
+ keywords = ["markdown", "frontmatter", "yaml", "zensical", "mkdocs", "macros"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.8",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Text Processing :: Markup",
26
+ ]
27
+ dependencies = [
28
+ "markdown>=3.4",
29
+ "pyyaml>=6.0",
30
+ "jinja2>=3.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=7",
36
+ "pytest-cov",
37
+ ]
38
+ docs = [
39
+ "zensical",
40
+ ]
41
+
42
+ [project.entry-points."markdown.extensions"]
43
+ markdown_macros = "markdown_macros:MacrosExtension"
44
+
45
+ [project.urls]
46
+ Documentation = "https://github.com/barcar/markdown-macros#readme"
47
+ Repository = "https://github.com/barcar/markdown-macros"
48
+
49
+ [tool.setuptools.packages.find]
50
+ where = ["."]
51
+ include = ["markdown_macros*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,75 @@
1
+ """Tests for the Front Matter extension."""
2
+
3
+ import markdown
4
+ from markdown_macros import FrontMatterExtension
5
+
6
+
7
+ def test_parses_yaml_front_matter():
8
+ text = """---
9
+ title: My Page
10
+ description: A short summary
11
+ tags:
12
+ - blog
13
+ - docs
14
+ ---
15
+
16
+ # Hello
17
+
18
+ Content here.
19
+ """
20
+ md = markdown.Markdown(extensions=[FrontMatterExtension()])
21
+ html = md.convert(text)
22
+
23
+ assert "title" in md.Meta
24
+ assert md.Meta["title"] == ["My Page"]
25
+ assert md.Meta["description"] == ["A short summary"]
26
+ assert md.Meta["tags"] == ["blog", "docs"]
27
+
28
+ assert md.front_matter["title"] == "My Page"
29
+ assert md.front_matter["description"] == "A short summary"
30
+ assert md.front_matter["tags"] == ["blog", "docs"]
31
+
32
+ assert "<h1>Hello</h1>" in html
33
+ assert "---" not in html
34
+ assert "title:" not in html
35
+
36
+
37
+ def test_no_front_matter():
38
+ text = "# No front matter\n\nJust content."
39
+ md = markdown.Markdown(extensions=[FrontMatterExtension()])
40
+ html = md.convert(text)
41
+
42
+ assert getattr(md, "Meta", {}) == {} or md.Meta == {}
43
+ assert getattr(md, "front_matter", {}) == {} or md.front_matter == {}
44
+ assert "<h1>No front matter</h1>" in html
45
+
46
+
47
+ def test_front_matter_extension_alone_sets_meta_and_front_matter():
48
+ """FrontMatterExtension only: no Jinja2; md.Meta and md.front_matter are set."""
49
+ text = """---
50
+ title: Solo
51
+ count: 1
52
+ ---
53
+
54
+ # Body
55
+ """
56
+ md = markdown.Markdown(extensions=[FrontMatterExtension()])
57
+ html = md.convert(text)
58
+ assert md.Meta.get("title") == ["Solo"]
59
+ assert md.Meta.get("count") == ["1"]
60
+ assert md.front_matter.get("title") == "Solo"
61
+ assert md.front_matter.get("count") == 1
62
+ assert "Body" in html
63
+
64
+
65
+ def test_extension_by_name():
66
+ text = """---
67
+ key: value
68
+ ---
69
+
70
+ Body.
71
+ """
72
+ html = markdown.markdown(text, extensions=["markdown_macros"])
73
+ # Extension runs when loaded by name; we can't easily get md.Meta from markdown()
74
+ # so just ensure no crash and body is present
75
+ assert "Body" in html
@@ -0,0 +1,136 @@
1
+ """Tests for the Macros (Jinja2) extension."""
2
+
3
+ import markdown
4
+ from markdown_macros import MacrosExtension
5
+
6
+
7
+ def test_include_yaml_list(tmp_path):
8
+ """Variables from include_yaml (list of paths) are in context."""
9
+ vars_file = tmp_path / "vars.yaml"
10
+ vars_file.write_text("from_yaml: loaded\nkey2: value2\n", encoding="utf-8")
11
+ text = "Result: {{ from_yaml }} and {{ key2 }}."
12
+ md = markdown.Markdown(
13
+ extensions=[
14
+ MacrosExtension(
15
+ include_yaml=[str(vars_file)],
16
+ project_root=str(tmp_path),
17
+ )
18
+ ]
19
+ )
20
+ html = md.convert(text)
21
+ assert "loaded" in html
22
+ assert "value2" in html
23
+
24
+
25
+ def test_include_yaml_dict(tmp_path):
26
+ """Variables from include_yaml (dict key→path) are nested under key."""
27
+ data_file = tmp_path / "data.yaml"
28
+ data_file.write_text("nested: value\n", encoding="utf-8")
29
+ text = "Result: {{ data.nested }}."
30
+ md = markdown.Markdown(
31
+ extensions=[
32
+ MacrosExtension(
33
+ include_yaml={"data": str(data_file)},
34
+ project_root=str(tmp_path),
35
+ )
36
+ ]
37
+ )
38
+ html = md.convert(text)
39
+ assert "value" in html
40
+
41
+
42
+ def test_front_matter_as_variables():
43
+ text = """---
44
+ title: My Page
45
+ price: 42
46
+ ---
47
+
48
+ # {{ title }}
49
+
50
+ Price is {{ price }}.
51
+ """
52
+ md = markdown.Markdown(extensions=[MacrosExtension()])
53
+ html = md.convert(text)
54
+ assert "My Page" in html
55
+ assert "42" in html
56
+ assert "{{" not in html
57
+ assert md.front_matter["title"] == "My Page"
58
+ assert md.front_matter["price"] == 42
59
+
60
+
61
+ def test_config_variables():
62
+ text = "Unit price: {{ unit_price }}."
63
+ md = markdown.Markdown(
64
+ extensions=[MacrosExtension(variables={"unit_price": 10})]
65
+ )
66
+ html = md.convert(text)
67
+ assert "10" in html
68
+ assert "{{" not in html
69
+
70
+
71
+ def test_no_jinja2_leave_unchanged():
72
+ text = "No variables here."
73
+ md = markdown.Markdown(extensions=[MacrosExtension()])
74
+ html = md.convert(text)
75
+ assert "No variables here" in html
76
+
77
+
78
+ def test_render_by_default_false_skips_without_render_macros():
79
+ text = """---
80
+ title: Page
81
+ ---
82
+
83
+ # {{ title }}
84
+ """
85
+ md = markdown.Markdown(
86
+ extensions=[MacrosExtension(render_by_default=False)]
87
+ )
88
+ html = md.convert(text)
89
+ # Without render_macros: true, Jinja2 is skipped
90
+ assert "{{ title }}" in html
91
+ assert md.front_matter["title"] == "Page"
92
+
93
+
94
+ def test_render_by_default_false_renders_when_render_macros_true():
95
+ text = """---
96
+ title: Page
97
+ render_macros: true
98
+ ---
99
+
100
+ # {{ title }}
101
+ """
102
+ md = markdown.Markdown(
103
+ extensions=[MacrosExtension(render_by_default=False)]
104
+ )
105
+ html = md.convert(text)
106
+ assert "Page" in html
107
+ assert "{{ title }}" not in html
108
+
109
+
110
+ def test_title_field_jinja2_expansion():
111
+ """MkDocs Macros compatibility: title in front matter can contain Jinja2."""
112
+ text = """---
113
+ title: Page for {{ product_name }}
114
+ product_name: Widget
115
+ ---
116
+
117
+ # {{ title }}
118
+
119
+ Content.
120
+ """
121
+ md = markdown.Markdown(extensions=[MacrosExtension()])
122
+ html = md.convert(text)
123
+ assert "Page for Widget" in html
124
+ assert md.front_matter["title"] == "Page for Widget"
125
+ assert "{{" not in html
126
+
127
+
128
+ def test_on_undefined_strict_raises():
129
+ import pytest
130
+ text = "Hello {{ missing }}"
131
+ # on_error_fail=True so the UndefinedError propagates
132
+ md = markdown.Markdown(
133
+ extensions=[MacrosExtension(on_undefined="strict", on_error_fail=True)]
134
+ )
135
+ with pytest.raises(Exception):
136
+ md.convert(text)