mkdocs-markdoc 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,24 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+ env/
13
+
14
+ # Node
15
+ node_modules/
16
+
17
+ # MkDocs built output
18
+ example/site/
19
+
20
+ # Editor / OS
21
+ .DS_Store
22
+ .idea/
23
+ .vscode/
24
+ *.swp
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: mkdocs-markdoc
3
+ Version: 0.1.0
4
+ Summary: MkDocs plugin that renders pages with Stripe's Markdoc instead of Python-Markdown
5
+ License: MIT
6
+ Keywords: documentation,markdoc,markdown,mkdocs,plugin
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Documentation
16
+ Classifier: Topic :: Software Development :: Documentation
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: mkdocs>=1.5
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest-mock>=3; extra == 'dev'
21
+ Requires-Dist: pytest>=7; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # mkdocs-markdoc
25
+
26
+ An MkDocs plugin that completely replaces the default Python-Markdown renderer
27
+ with **[Stripe's Markdoc](https://markdoc.dev)**. Every `.md` page in your
28
+ docs site is parsed and rendered by Markdoc's HTML renderer — no React
29
+ required.
30
+
31
+ ---
32
+
33
+ ## How it works
34
+
35
+ ```
36
+ MkDocs ──on_page_markdown──▶ plugin.py ──stdin──▶ markdoc_runner.js
37
+
38
+ HTML string ◀──stdout──────────────┘
39
+ ```
40
+
41
+ The Python plugin hooks into `on_page_markdown`, pipes the raw Markdown to a
42
+ bundled Node.js script via `subprocess`, and returns the HTML string that
43
+ MkDocs injects into the theme template.
44
+
45
+ ---
46
+
47
+ ## Prerequisites
48
+
49
+ | Requirement | Minimum version |
50
+ |-------------|----------------|
51
+ | Python | 3.9 |
52
+ | MkDocs | 1.5 |
53
+ | Node.js | 18 LTS |
54
+ | npm | 8 |
55
+
56
+ ---
57
+
58
+ ## Installation
59
+
60
+ ### 1 — Install the Python package
61
+
62
+ ```bash
63
+ # From the repo root (editable/development install)
64
+ pip install -e .
65
+
66
+ # Or once published to PyPI
67
+ pip install mkdocs-markdoc
68
+ ```
69
+
70
+ ### 2 — Install the Node.js Markdoc library
71
+
72
+ The `@markdoc/markdoc` npm package must be resolvable by Node.js when it runs
73
+ the bundled `markdoc_runner.js` script. Install it in **one** of these
74
+ locations (Node's module resolution will find it):
75
+
76
+ ```bash
77
+ # Option A – global install (simplest for local dev / CI)
78
+ npm install -g @markdoc/markdoc
79
+
80
+ # Option B – local install in your docs project root
81
+ cd /path/to/your/docs-project
82
+ npm install @markdoc/markdoc
83
+ ```
84
+
85
+ ### 3 — Enable the plugin in `mkdocs.yml`
86
+
87
+ ```yaml
88
+ # mkdocs.yml
89
+ site_name: My Docs
90
+
91
+ plugins:
92
+ - markdoc # ← add this; remove or comment out the default 'search'
93
+ # plugin only if you no longer need it
94
+ ```
95
+
96
+ > **Note:** MkDocs' built-in `search` plugin is independent of the Markdown
97
+ > renderer and can be kept alongside `markdoc`:
98
+ > ```yaml
99
+ > plugins:
100
+ > - search
101
+ > - markdoc
102
+ > ```
103
+
104
+ ---
105
+
106
+ ## Configuration options
107
+
108
+ All options are optional.
109
+
110
+ ```yaml
111
+ plugins:
112
+ - markdoc:
113
+ # Path to the Node.js executable.
114
+ # Default: "node" (resolved via $PATH)
115
+ node_path: /usr/local/bin/node
116
+
117
+ # Path to a JS or JSON file that exports a Markdoc config object.
118
+ # When omitted, Markdoc uses its built-in defaults (standard Markdown
119
+ # nodes, no custom tags or functions).
120
+ markdoc_config: docs/markdoc.config.js
121
+
122
+ # Milliseconds to wait for the Node subprocess before raising an error.
123
+ # Default: 30000 (30 seconds)
124
+ timeout: 30000
125
+ ```
126
+
127
+ ### Example `markdoc.config.js`
128
+
129
+ ```js
130
+ // docs/markdoc.config.js
131
+ const { nodes, Tag } = require("@markdoc/markdoc");
132
+
133
+ module.exports = {
134
+ tags: {
135
+ callout: {
136
+ render: "div",
137
+ attributes: {
138
+ type: { type: String, default: "note" },
139
+ },
140
+ },
141
+ },
142
+ nodes: {
143
+ // Override the default heading to add anchor IDs
144
+ heading: {
145
+ ...nodes.heading,
146
+ render: "h1",
147
+ },
148
+ },
149
+ };
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Running the docs locally
155
+
156
+ ```bash
157
+ mkdocs serve
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Caveats & trade-offs
163
+
164
+ * **Markdoc syntax differs from CommonMark.** Markdoc is a superset of
165
+ Markdown, but some edge-cases render differently. Review the
166
+ [Markdoc syntax reference](https://markdoc.dev/docs/syntax) when migrating
167
+ an existing docs site.
168
+ * **Node.js subprocess overhead.** Each page spawns (and immediately exits) one
169
+ Node process. For large sites with hundreds of pages the build time will
170
+ increase compared to the native Python-Markdown renderer. If this becomes a
171
+ bottleneck, consider batching pages in a future version.
172
+ * **MkDocs extensions are bypassed.** Because we skip Python-Markdown entirely,
173
+ any `markdown_extensions:` listed in `mkdocs.yml` will have no effect.
174
+ Equivalent behaviour must be implemented via Markdoc tags/nodes/functions.
@@ -0,0 +1,151 @@
1
+ # mkdocs-markdoc
2
+
3
+ An MkDocs plugin that completely replaces the default Python-Markdown renderer
4
+ with **[Stripe's Markdoc](https://markdoc.dev)**. Every `.md` page in your
5
+ docs site is parsed and rendered by Markdoc's HTML renderer — no React
6
+ required.
7
+
8
+ ---
9
+
10
+ ## How it works
11
+
12
+ ```
13
+ MkDocs ──on_page_markdown──▶ plugin.py ──stdin──▶ markdoc_runner.js
14
+
15
+ HTML string ◀──stdout──────────────┘
16
+ ```
17
+
18
+ The Python plugin hooks into `on_page_markdown`, pipes the raw Markdown to a
19
+ bundled Node.js script via `subprocess`, and returns the HTML string that
20
+ MkDocs injects into the theme template.
21
+
22
+ ---
23
+
24
+ ## Prerequisites
25
+
26
+ | Requirement | Minimum version |
27
+ |-------------|----------------|
28
+ | Python | 3.9 |
29
+ | MkDocs | 1.5 |
30
+ | Node.js | 18 LTS |
31
+ | npm | 8 |
32
+
33
+ ---
34
+
35
+ ## Installation
36
+
37
+ ### 1 — Install the Python package
38
+
39
+ ```bash
40
+ # From the repo root (editable/development install)
41
+ pip install -e .
42
+
43
+ # Or once published to PyPI
44
+ pip install mkdocs-markdoc
45
+ ```
46
+
47
+ ### 2 — Install the Node.js Markdoc library
48
+
49
+ The `@markdoc/markdoc` npm package must be resolvable by Node.js when it runs
50
+ the bundled `markdoc_runner.js` script. Install it in **one** of these
51
+ locations (Node's module resolution will find it):
52
+
53
+ ```bash
54
+ # Option A – global install (simplest for local dev / CI)
55
+ npm install -g @markdoc/markdoc
56
+
57
+ # Option B – local install in your docs project root
58
+ cd /path/to/your/docs-project
59
+ npm install @markdoc/markdoc
60
+ ```
61
+
62
+ ### 3 — Enable the plugin in `mkdocs.yml`
63
+
64
+ ```yaml
65
+ # mkdocs.yml
66
+ site_name: My Docs
67
+
68
+ plugins:
69
+ - markdoc # ← add this; remove or comment out the default 'search'
70
+ # plugin only if you no longer need it
71
+ ```
72
+
73
+ > **Note:** MkDocs' built-in `search` plugin is independent of the Markdown
74
+ > renderer and can be kept alongside `markdoc`:
75
+ > ```yaml
76
+ > plugins:
77
+ > - search
78
+ > - markdoc
79
+ > ```
80
+
81
+ ---
82
+
83
+ ## Configuration options
84
+
85
+ All options are optional.
86
+
87
+ ```yaml
88
+ plugins:
89
+ - markdoc:
90
+ # Path to the Node.js executable.
91
+ # Default: "node" (resolved via $PATH)
92
+ node_path: /usr/local/bin/node
93
+
94
+ # Path to a JS or JSON file that exports a Markdoc config object.
95
+ # When omitted, Markdoc uses its built-in defaults (standard Markdown
96
+ # nodes, no custom tags or functions).
97
+ markdoc_config: docs/markdoc.config.js
98
+
99
+ # Milliseconds to wait for the Node subprocess before raising an error.
100
+ # Default: 30000 (30 seconds)
101
+ timeout: 30000
102
+ ```
103
+
104
+ ### Example `markdoc.config.js`
105
+
106
+ ```js
107
+ // docs/markdoc.config.js
108
+ const { nodes, Tag } = require("@markdoc/markdoc");
109
+
110
+ module.exports = {
111
+ tags: {
112
+ callout: {
113
+ render: "div",
114
+ attributes: {
115
+ type: { type: String, default: "note" },
116
+ },
117
+ },
118
+ },
119
+ nodes: {
120
+ // Override the default heading to add anchor IDs
121
+ heading: {
122
+ ...nodes.heading,
123
+ render: "h1",
124
+ },
125
+ },
126
+ };
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Running the docs locally
132
+
133
+ ```bash
134
+ mkdocs serve
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Caveats & trade-offs
140
+
141
+ * **Markdoc syntax differs from CommonMark.** Markdoc is a superset of
142
+ Markdown, but some edge-cases render differently. Review the
143
+ [Markdoc syntax reference](https://markdoc.dev/docs/syntax) when migrating
144
+ an existing docs site.
145
+ * **Node.js subprocess overhead.** Each page spawns (and immediately exits) one
146
+ Node process. For large sites with hundreds of pages the build time will
147
+ increase compared to the native Python-Markdown renderer. If this becomes a
148
+ bottleneck, consider batching pages in a future version.
149
+ * **MkDocs extensions are bypassed.** Because we skip Python-Markdown entirely,
150
+ any `markdown_extensions:` listed in `mkdocs.yml` will have no effect.
151
+ Equivalent behaviour must be implemented via Markdoc tags/nodes/functions.
@@ -0,0 +1 @@
1
+ # mkdocs-markdoc: MkDocs plugin that renders pages with Stripe's Markdoc
@@ -0,0 +1,95 @@
1
+ /*
2
+ * markdoc.css — styling for elements that Markdoc emits but Material doesn't
3
+ * cover on its own (badges, details, code block tweaks).
4
+ *
5
+ * Most standard HTML elements (headings, tables, blockquotes, lists, etc.)
6
+ * are already styled by Material's .md-typeset rules because our plugin output
7
+ * lands inside that wrapper.
8
+ */
9
+
10
+ /* -------------------------------------------------------------------------
11
+ * Code blocks
12
+ * Our fence override renders <pre><code class="language-X">.
13
+ * highlight.js adds the `hljs` class; we normalise padding to match Material.
14
+ * ---------------------------------------------------------------------- */
15
+
16
+ .md-typeset pre > code.hljs {
17
+ padding: 1em 1.2em;
18
+ border-radius: 0.2rem;
19
+ font-size: 0.85em;
20
+ line-height: 1.6;
21
+ }
22
+
23
+ .md-typeset pre {
24
+ position: relative;
25
+ }
26
+
27
+ /* -------------------------------------------------------------------------
28
+ * Admonitions (callout → Material .admonition markup, styled for free)
29
+ * ---------------------------------------------------------------------- */
30
+
31
+ .md-typeset .admonition {
32
+ margin: 1.5em 0;
33
+ }
34
+
35
+ /* -------------------------------------------------------------------------
36
+ * Badge (inline colored chips)
37
+ * ---------------------------------------------------------------------- */
38
+
39
+ .md-typeset .mkd-badge {
40
+ display: inline-block;
41
+ padding: 0.1em 0.55em;
42
+ border-radius: 0.25rem;
43
+ font-size: 0.72em;
44
+ font-weight: 700;
45
+ letter-spacing: 0.02em;
46
+ line-height: 1.6;
47
+ vertical-align: middle;
48
+ white-space: nowrap;
49
+ }
50
+
51
+ .md-typeset .mkd-badge--blue { background-color: #1976d2; color: #fff; }
52
+ .md-typeset .mkd-badge--green { background-color: #388e3c; color: #fff; }
53
+ .md-typeset .mkd-badge--red { background-color: #d32f2f; color: #fff; }
54
+ .md-typeset .mkd-badge--orange { background-color: #e65100; color: #fff; }
55
+ .md-typeset .mkd-badge--grey { background-color: #616161; color: #fff; }
56
+ .md-typeset .mkd-badge--purple { background-color: #6a1b9a; color: #fff; }
57
+
58
+ /* -------------------------------------------------------------------------
59
+ * Details (collapsible <details>/<summary>)
60
+ *
61
+ * Material for MkDocs already styles all `details > summary` elements with
62
+ * its own disclosure icon (the right-side chevron) and flex layout. Adding
63
+ * our own ::before marker on top produces a double-icon overlap and pushes
64
+ * text mid-word. We therefore only set box-level styles here and let
65
+ * Material's built-in `details` CSS handle the indicator entirely.
66
+ * ---------------------------------------------------------------------- */
67
+
68
+ .md-typeset details.mkd-details {
69
+ border-left: 4px solid var(--md-primary-fg-color);
70
+ background-color: var(--md-admonition-bg-color, rgba(68, 138, 255, .05));
71
+ border-radius: 0.2rem;
72
+ padding: 0 1em;
73
+ margin: 1.25em 0;
74
+ }
75
+
76
+ /* Tighten summary text weight to make it feel like a heading */
77
+ .md-typeset details.mkd-details > summary {
78
+ font-weight: 600;
79
+ }
80
+
81
+ /* Separate summary from body when open */
82
+ .md-typeset details.mkd-details[open] > summary {
83
+ border-bottom: 1px solid var(--md-default-fg-color--lightest);
84
+ margin-bottom: 0.25em;
85
+ }
86
+
87
+ /* -------------------------------------------------------------------------
88
+ * Tables
89
+ * Markdoc renders plain <table> elements; Material styles them inside
90
+ * .md-typeset automatically. Enforce full width for consistency.
91
+ * ---------------------------------------------------------------------- */
92
+
93
+ .md-typeset table:not([class]) {
94
+ width: 100%;
95
+ }
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * markdoc_runner.js
4
+ *
5
+ * Reads raw Markdown from stdin, renders it to HTML with @markdoc/markdoc,
6
+ * and writes the HTML to stdout.
7
+ *
8
+ * Usage (invoked by the Python plugin):
9
+ * echo "# Hello" | node markdoc_runner.js [--config <path>]
10
+ *
11
+ * Exit codes:
12
+ * 0 success – HTML written to stdout
13
+ * 1 error – message written to stderr
14
+ */
15
+
16
+ "use strict";
17
+
18
+ const path = require("path");
19
+ const fs = require("fs");
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Argument parsing (minimal – only --config <path> is supported)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ function parseArgs(argv) {
26
+ const args = { configPath: null };
27
+ for (let i = 2; i < argv.length; i++) {
28
+ if (argv[i] === "--config" && argv[i + 1]) {
29
+ args.configPath = path.resolve(argv[++i]);
30
+ }
31
+ }
32
+ return args;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Load optional Markdoc config
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function loadMarkdocConfig(configPath) {
40
+ if (!configPath) return {};
41
+
42
+ if (!fs.existsSync(configPath)) {
43
+ throw new Error(`Markdoc config file not found: ${configPath}`);
44
+ }
45
+
46
+ const ext = path.extname(configPath).toLowerCase();
47
+
48
+ if (ext === ".json") {
49
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
50
+ }
51
+
52
+ // Treat .js / .cjs as a CommonJS module exporting a config object.
53
+ // The module may export the config directly or as `module.exports.default`.
54
+ const mod = require(configPath);
55
+ return mod.default ?? mod;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Render pipeline
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function renderMarkdoc(source, markdocConfig) {
63
+ // Lazy-require so a missing package produces a clear error message.
64
+ let Markdoc;
65
+ try {
66
+ Markdoc = require("@markdoc/markdoc");
67
+ } catch (err) {
68
+ throw new Error(
69
+ "@markdoc/markdoc is not installed. " +
70
+ "Run `npm install @markdoc/markdoc` in the plugin directory or globally.\n" +
71
+ err.message
72
+ );
73
+ }
74
+
75
+ // 1. Parse – produces an AST.
76
+ const ast = Markdoc.parse(source);
77
+
78
+ // 2. Validate – surface any Markdoc schema errors before transforming.
79
+ const errors = Markdoc.validate(ast, markdocConfig);
80
+ if (errors.length > 0) {
81
+ const messages = errors
82
+ .map((e) => ` [${e.error.level}] ${e.error.message} (line ${e.lines?.[0] ?? "?"})`)
83
+ .join("\n");
84
+
85
+ // Only hard-fail on actual errors; warnings/hints are logged to stderr.
86
+ const fatal = errors.filter((e) => e.error.level === "error");
87
+ if (fatal.length > 0) {
88
+ throw new Error(`Markdoc validation errors:\n${messages}`);
89
+ }
90
+
91
+ process.stderr.write(`mkdocs-markdoc: Markdoc warnings:\n${messages}\n`);
92
+ }
93
+
94
+ // 3. Transform – converts AST to a renderable tree using the config.
95
+ const renderableTree = Markdoc.transform(ast, markdocConfig);
96
+
97
+ // 4. Render to plain HTML (no React dependency).
98
+ return Markdoc.renderers.html(renderableTree);
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Main – read stdin, render, write stdout
103
+ // ---------------------------------------------------------------------------
104
+
105
+ async function main() {
106
+ const args = parseArgs(process.argv);
107
+
108
+ let markdocConfig;
109
+ try {
110
+ markdocConfig = loadMarkdocConfig(args.configPath);
111
+ } catch (err) {
112
+ process.stderr.write(`mkdocs-markdoc: failed to load config: ${err.message}\n`);
113
+ process.exit(1);
114
+ }
115
+
116
+ // Collect stdin into a single string.
117
+ const chunks = [];
118
+ for await (const chunk of process.stdin) {
119
+ chunks.push(chunk);
120
+ }
121
+ const source = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
122
+
123
+ let html;
124
+ try {
125
+ html = renderMarkdoc(source, markdocConfig);
126
+ } catch (err) {
127
+ process.stderr.write(`mkdocs-markdoc: render error: ${err.message}\n`);
128
+ process.exit(1);
129
+ }
130
+
131
+ process.stdout.write(html);
132
+ }
133
+
134
+ main().catch((err) => {
135
+ process.stderr.write(`mkdocs-markdoc: unexpected error: ${err.message}\n`);
136
+ process.exit(1);
137
+ });
@@ -0,0 +1,246 @@
1
+ """
2
+ MkDocs plugin that replaces the default Python-Markdown renderer with
3
+ Stripe's Markdoc, via a bundled Node.js subprocess.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import shutil
10
+ import subprocess
11
+ from html.parser import HTMLParser
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from mkdocs.config import config_options
16
+ from mkdocs.config.base import Config
17
+ from mkdocs.plugins import BasePlugin
18
+ from mkdocs.structure.files import File, Files
19
+ from mkdocs.structure.pages import Page
20
+ from mkdocs.structure.toc import AnchorLink, TableOfContents
21
+
22
+ log = logging.getLogger("mkdocs.plugins.markdoc")
23
+
24
+ # Absolute path to the bundled Node.js runner so it works regardless of cwd.
25
+ _RUNNER_PATH = Path(__file__).parent / "markdoc_runner.js"
26
+
27
+ _HEADING_TAGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"})
28
+
29
+
30
+ class _HeadingParser(HTMLParser):
31
+ """Extract heading text + existing id attributes from rendered HTML."""
32
+
33
+ def __init__(self) -> None:
34
+ super().__init__()
35
+ self.headings: list[dict] = []
36
+ self._tag: str = ""
37
+ self._id: str = ""
38
+ self._text: list[str] = []
39
+
40
+ def handle_starttag(self, tag: str, attrs: list) -> None:
41
+ if tag in _HEADING_TAGS:
42
+ self._tag = tag
43
+ self._id = dict(attrs).get("id", "")
44
+ self._text = []
45
+
46
+ def handle_endtag(self, tag: str) -> None:
47
+ if tag == self._tag and tag in _HEADING_TAGS:
48
+ self.headings.append({
49
+ "level": int(self._tag[1]),
50
+ "id": self._id,
51
+ "text": "".join(self._text).strip(),
52
+ })
53
+ self._tag = ""
54
+
55
+ def handle_data(self, data: str) -> None:
56
+ if self._tag:
57
+ self._text.append(data)
58
+
59
+
60
+ def _toc_from_html(html: str) -> TableOfContents:
61
+ """
62
+ Build a MkDocs TableOfContents from <hN id="..."> elements in the HTML.
63
+
64
+ The heading node override in markdoc.config.js guarantees every heading
65
+ already carries an id attribute, so no HTML modification is needed here.
66
+ Headings without an id (e.g. those inside custom tag blocks that swallow
67
+ them) are silently skipped.
68
+ """
69
+ parser = _HeadingParser()
70
+ parser.feed(html)
71
+
72
+ top: list[AnchorLink] = []
73
+ stack: list[AnchorLink] = []
74
+
75
+ for h in parser.headings:
76
+ if not h["id"]:
77
+ continue
78
+ link = AnchorLink(title=h["text"], id=h["id"], level=h["level"])
79
+ while stack and stack[-1].level >= h["level"]:
80
+ stack.pop()
81
+ if stack:
82
+ stack[-1].children.append(link)
83
+ else:
84
+ top.append(link)
85
+ stack.append(link)
86
+
87
+ return TableOfContents(top)
88
+
89
+
90
+ class MarkdocPluginConfig(Config):
91
+ # Path to the `node` executable. Defaults to whatever is on $PATH.
92
+ node_path = config_options.Type(str, default="node")
93
+
94
+ # Optional: path to a Markdoc config JS/JSON file that the runner will
95
+ # `require()`. When empty the runner uses bare Markdoc defaults.
96
+ markdoc_config = config_options.Optional(config_options.File(exists=True))
97
+
98
+ # Milliseconds before the Node subprocess is killed and an error raised.
99
+ timeout = config_options.Type(int, default=30_000)
100
+
101
+
102
+ class MarkdocPlugin(BasePlugin[MarkdocPluginConfig]):
103
+ """
104
+ Intercepts raw Markdown on every page and hands it to the Node.js Markdoc
105
+ runner. The runner returns a plain HTML string that MkDocs then injects
106
+ into its theme template exactly as it would with the normal renderer.
107
+
108
+ Lifecycle
109
+ ---------
110
+ on_config – validate that Node.js is available once, up front.
111
+ on_page_markdown – convert each page's Markdown to HTML via subprocess.
112
+ """
113
+
114
+ def on_config(self, config: dict[str, Any]) -> dict[str, Any]:
115
+ node_exec = self.config["node_path"]
116
+
117
+ # Resolve "node" to a full path so the error message is unambiguous.
118
+ resolved = shutil.which(node_exec)
119
+ if resolved is None:
120
+ raise RuntimeError(
121
+ f"mkdocs-markdoc: Node.js executable '{node_exec}' not found. "
122
+ "Install Node.js (https://nodejs.org) or set the `node_path` "
123
+ "option in your mkdocs.yml plugin configuration."
124
+ )
125
+
126
+ self._node_exec = resolved
127
+ log.debug("mkdocs-markdoc: using Node.js at %s", resolved)
128
+
129
+ # Inject the bundled stylesheet so it loads before any user extra_css.
130
+ config.setdefault("extra_css", []).insert(0, "assets/markdoc.css")
131
+
132
+ # Verify @markdoc/markdoc is installed where the runner can reach it.
133
+ check = self._run_node(
134
+ "require('@markdoc/markdoc'); process.stdout.write('ok');"
135
+ )
136
+ if check.returncode != 0 or check.stdout.strip() != "ok":
137
+ stderr = check.stderr.strip()
138
+ raise RuntimeError(
139
+ "mkdocs-markdoc: @markdoc/markdoc is not importable from the "
140
+ "Node.js runner. Run `npm install @markdoc/markdoc` (globally "
141
+ f"or in the project directory).\nNode stderr: {stderr}"
142
+ )
143
+
144
+ return config
145
+
146
+ def on_files(self, files: Files, config: dict[str, Any], **kwargs: Any) -> Files:
147
+ """Inject the bundled markdoc.css into the MkDocs file collection."""
148
+ files.append(File(
149
+ path="assets/markdoc.css",
150
+ src_dir=str(Path(__file__).parent),
151
+ dest_dir=config["site_dir"],
152
+ use_directory_urls=config["use_directory_urls"],
153
+ ))
154
+ return files
155
+
156
+ # ------------------------------------------------------------------
157
+ # Core hook
158
+ # ------------------------------------------------------------------
159
+
160
+ def on_page_markdown(
161
+ self,
162
+ markdown: str,
163
+ page: Page,
164
+ config: dict[str, Any],
165
+ **kwargs: Any,
166
+ ) -> str:
167
+ """
168
+ Called by MkDocs with the raw Markdown string for every page.
169
+ Returns the rendered HTML string.
170
+ """
171
+ try:
172
+ result = self._run_node_runner(markdown)
173
+ except FileNotFoundError:
174
+ # Node executable disappeared between on_config and now.
175
+ raise RuntimeError(
176
+ f"mkdocs-markdoc: Node.js executable '{self._node_exec}' "
177
+ "disappeared during the build."
178
+ )
179
+ except subprocess.TimeoutExpired:
180
+ raise RuntimeError(
181
+ f"mkdocs-markdoc: Node.js subprocess timed out after "
182
+ f"{self.config['timeout']} ms while processing "
183
+ f"'{page.file.src_path}'."
184
+ )
185
+
186
+ if result.returncode != 0:
187
+ stderr = result.stderr.strip()
188
+ raise RuntimeError(
189
+ f"mkdocs-markdoc: Markdoc rendering failed for "
190
+ f"'{page.file.src_path}'.\nNode stderr: {stderr}"
191
+ )
192
+
193
+ html = result.stdout
194
+ if not html:
195
+ log.warning(
196
+ "mkdocs-markdoc: empty HTML output for '%s' – "
197
+ "returning empty string.",
198
+ page.file.src_path,
199
+ )
200
+
201
+ return html
202
+
203
+ def on_page_content(
204
+ self,
205
+ html: str,
206
+ page: Page,
207
+ config: dict[str, Any],
208
+ **kwargs: Any,
209
+ ) -> str:
210
+ """
211
+ Called by MkDocs after Python-Markdown has processed the page.
212
+
213
+ Because we bypass Python-Markdown entirely, page.toc is empty after
214
+ page.render() — the toc extension never sees any Markdown headings.
215
+ We rebuild it here by parsing the id-annotated <hN> elements that
216
+ the heading node override in markdoc.config.js already produced.
217
+ """
218
+ page.toc = _toc_from_html(html)
219
+ return html
220
+
221
+ # ------------------------------------------------------------------
222
+ # Helpers
223
+ # ------------------------------------------------------------------
224
+
225
+ def _run_node_runner(self, markdown: str) -> subprocess.CompletedProcess:
226
+ """Pipe *markdown* into markdoc_runner.js and return the result."""
227
+ cmd = [self._node_exec, str(_RUNNER_PATH)]
228
+ if self.config["markdoc_config"]:
229
+ cmd += ["--config", self.config["markdoc_config"]]
230
+
231
+ return subprocess.run(
232
+ cmd,
233
+ input=markdown,
234
+ capture_output=True,
235
+ text=True,
236
+ timeout=self.config["timeout"] / 1000, # subprocess uses seconds
237
+ )
238
+
239
+ def _run_node(self, script: str) -> subprocess.CompletedProcess:
240
+ """Run an inline Node.js *script* string for quick checks."""
241
+ return subprocess.run(
242
+ [self._node_exec, "-e", script],
243
+ capture_output=True,
244
+ text=True,
245
+ timeout=10,
246
+ )
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mkdocs-markdoc"
7
+ version = "0.1.0"
8
+ description = "MkDocs plugin that renders pages with Stripe's Markdoc instead of Python-Markdown"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ keywords = ["mkdocs", "markdoc", "markdown", "plugin", "documentation"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Documentation",
23
+ "Topic :: Software Development :: Documentation",
24
+ ]
25
+ dependencies = [
26
+ "mkdocs>=1.5",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=7",
32
+ "pytest-mock>=3",
33
+ ]
34
+
35
+ [project.entry-points."mkdocs.plugins"]
36
+ markdoc = "mkdocs_markdoc.plugin:MarkdocPlugin"
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Hatch build – make sure the JS runner is included in the wheel/sdist.
40
+ # ---------------------------------------------------------------------------
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["mkdocs_markdoc"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "mkdocs_markdoc/**",
48
+ "README.md",
49
+ "pyproject.toml",
50
+ ]
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Ruff (optional – only applied when ruff is installed)
54
+ # ---------------------------------------------------------------------------
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py39"