markwright 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markwright
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python-Markdown extensions ported from DigitalOcean's do-markdownit
5
5
  Keywords: markdown,python-markdown,do-markdownit,mkdocs,hugo
6
6
  Author: Mason Egger
@@ -26,11 +26,11 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # markwright
28
28
 
29
- A Python port of DigitalOcean's [`do-markdownit`](https://github.com/digitalocean/do-markdownit) JavaScript library.
29
+ markwright is a set of [Python-Markdown](https://python-markdown.github.io/) extensions plus a companion command-line tool, `mw`, for author-friendly markdown: highlighted text, labeled code fences, and a set of media embeds.
30
+ Use the extensions in any tool built on Python-Markdown (including [MkDocs](https://www.mkdocs.org/), Flask, or a plain script), or the `mw` pipeline in toolchains that are not, like Hugo.
30
31
 
31
- These are [Python-Markdown](https://python-markdown.github.io/) extensions that bring DigitalOcean's markdown syntax (highlighted text, labeled code fences, and a set of media embeds) to any tool built on Python-Markdown, including [MkDocs](https://www.mkdocs.org/), Flask, or a plain script.
32
- The port maintains compatibility with the original `do-markdownit` HTML output, so rendered pages match what DigitalOcean produces.
33
- A bundled command-line tool, `mw`, runs the same extensions as pre and post filters around any renderer, so the syntax also works in toolchains that are not built on Python-Markdown, like Hugo.
32
+ The author-facing syntax and HTML output are derived from DigitalOcean's [`do-markdownit`](https://github.com/digitalocean/do-markdownit), so rendered pages match what DigitalOcean produces.
33
+ markwright ports those extensions to Python-Markdown and adds its own pieces on top, most notably the `mw` CLI, which runs the same extensions as pre and post filters around any renderer.
34
34
 
35
35
  Full documentation and a live demo: https://masonegger.github.io/markwright/
36
36
 
@@ -139,8 +139,12 @@ mw pre < in.md | your-renderer | mw post > out.html
139
139
  - `mw pre` expands the embeds and extracts fence directives into an `mw-fence` comment.
140
140
  - `mw post` applies the fence styling, resolves the highlights, and injects each embed script once.
141
141
  - `mw render` runs the full pipeline in one shot for callers without their own renderer.
142
+ - `mw config` prints the resolved configuration and the source of each value.
142
143
  - `mw list` prints every extension and the stages it provides.
143
144
 
145
+ Which extensions run, and how each is tuned, come from a `markwright.toml` (or a `[tool.markwright]` table in `pyproject.toml`) that the CLI discovers by walking up from the current directory.
146
+ See the [configuration docs](https://masonegger.github.io/markwright/config/).
147
+
144
148
  Your renderer must pass raw HTML and HTML comments through.
145
149
  Hugo is a worked, tested example: see the [Hugo guide](https://masonegger.github.io/markwright/integrations/hugo/).
146
150
  The full command and flag reference is in the [CLI docs](https://masonegger.github.io/markwright/cli/).
@@ -1,10 +1,10 @@
1
1
  # markwright
2
2
 
3
- A Python port of DigitalOcean's [`do-markdownit`](https://github.com/digitalocean/do-markdownit) JavaScript library.
3
+ markwright is a set of [Python-Markdown](https://python-markdown.github.io/) extensions plus a companion command-line tool, `mw`, for author-friendly markdown: highlighted text, labeled code fences, and a set of media embeds.
4
+ Use the extensions in any tool built on Python-Markdown (including [MkDocs](https://www.mkdocs.org/), Flask, or a plain script), or the `mw` pipeline in toolchains that are not, like Hugo.
4
5
 
5
- These are [Python-Markdown](https://python-markdown.github.io/) extensions that bring DigitalOcean's markdown syntax (highlighted text, labeled code fences, and a set of media embeds) to any tool built on Python-Markdown, including [MkDocs](https://www.mkdocs.org/), Flask, or a plain script.
6
- The port maintains compatibility with the original `do-markdownit` HTML output, so rendered pages match what DigitalOcean produces.
7
- A bundled command-line tool, `mw`, runs the same extensions as pre and post filters around any renderer, so the syntax also works in toolchains that are not built on Python-Markdown, like Hugo.
6
+ The author-facing syntax and HTML output are derived from DigitalOcean's [`do-markdownit`](https://github.com/digitalocean/do-markdownit), so rendered pages match what DigitalOcean produces.
7
+ markwright ports those extensions to Python-Markdown and adds its own pieces on top, most notably the `mw` CLI, which runs the same extensions as pre and post filters around any renderer.
8
8
 
9
9
  Full documentation and a live demo: https://masonegger.github.io/markwright/
10
10
 
@@ -113,8 +113,12 @@ mw pre < in.md | your-renderer | mw post > out.html
113
113
  - `mw pre` expands the embeds and extracts fence directives into an `mw-fence` comment.
114
114
  - `mw post` applies the fence styling, resolves the highlights, and injects each embed script once.
115
115
  - `mw render` runs the full pipeline in one shot for callers without their own renderer.
116
+ - `mw config` prints the resolved configuration and the source of each value.
116
117
  - `mw list` prints every extension and the stages it provides.
117
118
 
119
+ Which extensions run, and how each is tuned, come from a `markwright.toml` (or a `[tool.markwright]` table in `pyproject.toml`) that the CLI discovers by walking up from the current directory.
120
+ See the [configuration docs](https://masonegger.github.io/markwright/config/).
121
+
118
122
  Your renderer must pass raw HTML and HTML comments through.
119
123
  Hugo is a worked, tested example: see the [Hugo guide](https://masonegger.github.io/markwright/integrations/hugo/).
120
124
  The full command and flag reference is in the [CLI docs](https://masonegger.github.io/markwright/cli/).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "markwright"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Python-Markdown extensions ported from DigitalOcean's do-markdownit"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "markwright"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Python-Markdown extensions ported from DigitalOcean's do-markdownit"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,245 @@
1
+ # ABOUTME: Command-line entry point for the mw markwright pipeline tool.
2
+ # Builds the argparse parser and dispatches list/pre/post/render; --version reports the package version.
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from importlib.metadata import version
9
+ from pathlib import Path
10
+
11
+ import markdown
12
+
13
+ from markwright import config, registry
14
+ from markwright.config import Config, ConfigError
15
+
16
+
17
+ def _package_version() -> str:
18
+ """Return the installed markwright distribution version.
19
+
20
+ :returns: The version string for the ``markwright`` distribution.
21
+ """
22
+ return version("markwright")
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ """Construct the ``mw`` argument parser with its subcommands.
27
+
28
+ :returns: A parser exposing ``--version`` and the ``list`` subcommand.
29
+ """
30
+ parser = argparse.ArgumentParser(prog="mw", description="markwright Markdown pipeline CLI.")
31
+ parser.add_argument("--version", action="version", version=f"mw {_package_version()}")
32
+ subparsers = parser.add_subparsers(dest="command")
33
+ subparsers.add_parser("list", help="List registered extensions and the stages each provides.")
34
+ pre_parser = subparsers.add_parser("pre", help="Expand markwright source directives read from stdin.")
35
+ _add_selection_flags(pre_parser)
36
+ post_parser = subparsers.add_parser("post", help="Post-process rendered HTML read from stdin.")
37
+ _add_selection_flags(post_parser)
38
+ post_parser.add_argument("--warn", action="store_true", help="Report skipped markers to stderr.")
39
+ render_parser = subparsers.add_parser("render", help="Render Markdown from stdin to final HTML.")
40
+ _add_selection_flags(render_parser)
41
+ config_parser = subparsers.add_parser("config", help="Print the resolved configuration and each value's source.")
42
+ _add_selection_flags(config_parser)
43
+ return parser
44
+
45
+
46
+ def _add_selection_flags(subparser: argparse.ArgumentParser) -> None:
47
+ """Add the shared ``--config`` and ``--exclude`` selection flags to a subparser.
48
+
49
+ :param subparser: The subcommand parser to extend.
50
+ """
51
+ subparser.add_argument("--config", default=None, help="Load configuration from PATH, bypassing discovery.")
52
+ subparser.add_argument("--exclude", action="append", default=[], help="Drop the named extension (repeatable).")
53
+
54
+
55
+ def _run_list() -> int:
56
+ """Print each registered extension and its available stages.
57
+
58
+ :returns: Always ``0``.
59
+ """
60
+ for name, stages in registry.describe():
61
+ print(f"{name}: {', '.join(stages)}")
62
+ return 0
63
+
64
+
65
+ def _resolve(args: argparse.Namespace) -> tuple[Config, list[str]] | None:
66
+ """Load config and resolve the active extension names, reporting failures to stderr.
67
+
68
+ Selection resolves from config (``enable`` allowlist or ``disable`` denylist) with the
69
+ repeatable CLI ``--exclude`` applied on top. A config error or an unknown extension name
70
+ is reported to stderr and yields ``None`` so the caller returns exit code ``2``.
71
+
72
+ :param args: Parsed arguments carrying ``config`` and ``exclude``.
73
+ :returns: The resolved config and selected extension names, or ``None`` on failure.
74
+ """
75
+ explicit_path = Path(args.config) if args.config else None
76
+ try:
77
+ resolved_config = config.load_config(explicit_path, Path.cwd())
78
+ except ConfigError as config_error:
79
+ print(config_error, file=sys.stderr)
80
+ return None
81
+ use = list(resolved_config.enable) if resolved_config.enable is not None else []
82
+ exclude = [*resolved_config.disable, *args.exclude]
83
+ try:
84
+ names = registry.select_extensions(use, exclude)
85
+ except ValueError as selection_error:
86
+ print(selection_error, file=sys.stderr)
87
+ return None
88
+ return resolved_config, names
89
+
90
+
91
+ def _extension_configs(resolved_config: Config, names: list[str]) -> dict[str, dict[str, object]]:
92
+ """Assemble ``markdown.Markdown`` extension configs from the resolved config.
93
+
94
+ Each selected ``markwright.*`` extension with a per-extension option table contributes
95
+ it, alongside the fixed ``pymdownx.highlight`` entry that keeps language classes on.
96
+
97
+ :param resolved_config: The resolved config carrying per-extension option dicts.
98
+ :param names: The selected extension names.
99
+ :returns: A mapping of extension name to its option dict.
100
+ """
101
+ configs: dict[str, dict[str, object]] = {"pymdownx.highlight": {"pygments_lang_class": True}}
102
+ for name in names:
103
+ options = resolved_config.options.get(name)
104
+ if options:
105
+ configs[f"markwright.{name}"] = options
106
+ return configs
107
+
108
+
109
+ def _run_pre(args: argparse.Namespace) -> int:
110
+ """Expand source directives from stdin and write the result to stdout.
111
+
112
+ :param args: Parsed arguments carrying ``config`` and ``exclude``.
113
+ :returns: ``0`` on success, ``2`` if config fails to load or a name is unknown.
114
+ """
115
+ resolved = _resolve(args)
116
+ if resolved is None:
117
+ return 2
118
+ resolved_config, names = resolved
119
+ sys.stdout.write(registry.run_pre(sys.stdin.read(), names, resolved_config.options))
120
+ return 0
121
+
122
+
123
+ def _run_post(args: argparse.Namespace) -> int:
124
+ """Post-process HTML from stdin and write the result to stdout.
125
+
126
+ :param args: Parsed arguments carrying ``config``, ``exclude``, and ``warn``.
127
+ :returns: ``0`` on success, ``2`` if config fails to load or a name is unknown.
128
+ """
129
+ resolved = _resolve(args)
130
+ if resolved is None:
131
+ return 2
132
+ _, names = resolved
133
+ warnings: list[str] | None = [] if args.warn else None
134
+ rendered_html = registry.run_post(sys.stdin.read(), names, warnings)
135
+ sys.stdout.write(rendered_html)
136
+ if warnings is not None:
137
+ for warning in warnings:
138
+ print(warning, file=sys.stderr)
139
+ return 0
140
+
141
+
142
+ def _run_render(args: argparse.Namespace) -> int:
143
+ """Render Markdown from stdin to final HTML using the in-process stack.
144
+
145
+ Builds a ``markdown.Markdown`` instance configured with ``pymdownx.superfences``
146
+ and ``pymdownx.highlight`` plus the selected ``markwright.*`` extensions, mirroring
147
+ the site stack so fence and highlight render correctly.
148
+
149
+ :param args: Parsed arguments carrying ``config`` and ``exclude``.
150
+ :returns: ``0`` on success, ``2`` if config fails to load or a name is unknown.
151
+ """
152
+ resolved = _resolve(args)
153
+ if resolved is None:
154
+ return 2
155
+ resolved_config, names = resolved
156
+ instance = markdown.Markdown(
157
+ extensions=["pymdownx.superfences", "pymdownx.highlight", *(f"markwright.{name}" for name in names)],
158
+ extension_configs=_extension_configs(resolved_config, names),
159
+ )
160
+ sys.stdout.write(instance.convert(sys.stdin.read()))
161
+ return 0
162
+
163
+
164
+ def _extension_status(name: str, resolved_config: Config, excluded: list[str]) -> tuple[str, str]:
165
+ """Report whether an extension is on or off and where that decision came from.
166
+
167
+ Precedence matches selection: a CLI ``--exclude`` wins, then a config ``enable`` allowlist
168
+ or ``disable`` denylist, then the all-on default.
169
+
170
+ :param name: The extension name.
171
+ :param resolved_config: The resolved config.
172
+ :param excluded: Names passed to ``--exclude``.
173
+ :returns: An ``(status, source)`` pair, ``status`` being ``"on"`` or ``"off"``.
174
+ """
175
+ config_source = str(resolved_config.source_path) if resolved_config.source_path is not None else "default"
176
+ if name in excluded:
177
+ return "off", "--exclude"
178
+ if resolved_config.enable is not None:
179
+ return ("on" if name in resolved_config.enable else "off"), config_source
180
+ if name in resolved_config.disable:
181
+ return "off", config_source
182
+ return "on", "default"
183
+
184
+
185
+ def _format_config(resolved_config: Config, excluded: list[str]) -> list[str]:
186
+ """Format the resolved configuration and value sources as printable lines.
187
+
188
+ :param resolved_config: The resolved config.
189
+ :param excluded: Names passed to ``--exclude``.
190
+ :returns: Output lines for ``mw config``, one per rendered value.
191
+ """
192
+ lines = ["extensions:"]
193
+ for name in registry.EXTENSION_NAMES:
194
+ status, source = _extension_status(name, resolved_config, excluded)
195
+ lines.append(f" {name}: {status} ({source})")
196
+ warn_source = resolved_config.sources.get("warn", "default")
197
+ lines.append(f"warn: {str(resolved_config.warn).lower()} ({warn_source})")
198
+ if resolved_config.options:
199
+ lines.append("options:")
200
+ for extension_name, option_table in resolved_config.options.items():
201
+ source = resolved_config.sources.get(extension_name, "default")
202
+ lines.append(f" {extension_name}: {option_table} ({source})")
203
+ else:
204
+ lines.append("options: none")
205
+ return lines
206
+
207
+
208
+ def _run_config(args: argparse.Namespace) -> int:
209
+ """Print the resolved configuration and the source of each value.
210
+
211
+ :param args: Parsed arguments carrying ``config`` and ``exclude``.
212
+ :returns: ``0`` on success, ``2`` if config fails to load or a name is unknown.
213
+ """
214
+ resolved = _resolve(args)
215
+ if resolved is None:
216
+ return 2
217
+ resolved_config, _ = resolved
218
+ for line in _format_config(resolved_config, args.exclude):
219
+ print(line)
220
+ return 0
221
+
222
+
223
+ def main(argv: list[str] | None = None) -> int:
224
+ """Parse ``argv`` and dispatch to the selected subcommand.
225
+
226
+ :param argv: Argument vector, or ``None`` to read from ``sys.argv``.
227
+ :returns: Process exit code (``0`` success, ``2`` usage error).
228
+ """
229
+ parser = build_parser()
230
+ try:
231
+ args = parser.parse_args(argv)
232
+ except SystemExit as exit_error:
233
+ return exit_error.code if isinstance(exit_error.code, int) else 2
234
+ if args.command == "list":
235
+ return _run_list()
236
+ if args.command == "pre":
237
+ return _run_pre(args)
238
+ if args.command == "post":
239
+ return _run_post(args)
240
+ if args.command == "render":
241
+ return _run_render(args)
242
+ if args.command == "config":
243
+ return _run_config(args)
244
+ parser.print_usage()
245
+ return 2
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
  import html
7
7
  import re
8
8
  import urllib.parse
9
+ from collections.abc import Mapping
9
10
 
10
11
  from markdown import Markdown
11
12
  from markdown.extensions import Extension
@@ -115,13 +116,14 @@ def _render_match(line: str) -> str | None:
115
116
  )
116
117
 
117
118
 
118
- def expand_source(text: str) -> str:
119
+ def expand_source(text: str, options: Mapping[str, object] | None = None) -> str:
119
120
  """Expand standalone CodePen embed lines to embed HTML in raw source.
120
121
 
121
122
  Used by the ``mw pre`` CLI stage. Unlike the preprocessor, this emits the
122
123
  HTML inline without any Python-Markdown stash placeholder.
123
124
 
124
125
  :param text: The source text.
126
+ :param options: Ignored; accepted so the registry calls every pre stage with one signature.
125
127
  :returns: The text with standalone CodePen embeds replaced by HTML.
126
128
  """
127
129
  return "\n".join(_render_match(line) or line for line in text.split("\n"))
@@ -0,0 +1,220 @@
1
+ # ABOUTME: Config discovery, TOML parsing, validation, and the resolved Config object.
2
+ # Owns the markwright.toml / [tool.markwright] tuning surface; no dependency on cli.
3
+
4
+ from __future__ import annotations
5
+
6
+ import tomllib
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from markwright.registry import EXTENSION_NAMES
12
+
13
+ _TOP_LEVEL_KEYS: frozenset[str] = frozenset({"enable", "disable", "warn"})
14
+
15
+
16
+ class ConfigError(Exception):
17
+ """Raised for any config discovery, parse, or validation failure.
18
+
19
+ Carries a message meant for the user at the CLI boundary, never a traceback.
20
+ """
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Config:
25
+ """Resolved markwright configuration.
26
+
27
+ :ivar enable: Allowlist of extension names, or ``None`` when unset.
28
+ :ivar disable: Denylist of extension names, empty when unset.
29
+ :ivar options: Per-extension option tables keyed by extension name.
30
+ :ivar warn: Default for the post stage's marker warnings.
31
+ :ivar source_path: The file this config was loaded from, or ``None`` for built-in defaults.
32
+ :ivar sources: Maps each key set in the file (``enable``, ``disable``, ``warn``, or an
33
+ extension name) to the file path it came from; keys not present use defaults.
34
+ """
35
+
36
+ enable: tuple[str, ...] | None = None
37
+ disable: tuple[str, ...] = ()
38
+ options: dict[str, dict[str, object]] = field(default_factory=dict)
39
+ warn: bool = False
40
+ source_path: Path | None = None
41
+ sources: dict[str, str] = field(default_factory=dict)
42
+
43
+
44
+ def load_config(explicit_path: Path | None, start_dir: Path) -> Config:
45
+ """Resolve the configuration from an explicit path or by walk-up discovery.
46
+
47
+ :param explicit_path: A specific config file to load, bypassing discovery, or ``None``.
48
+ :param start_dir: The directory to begin walk-up discovery from when no explicit path is given.
49
+ :returns: The resolved :class:`Config`; built-in defaults when nothing is found.
50
+ :raises ConfigError: If a file is missing, unparseable, or fails schema validation.
51
+ """
52
+ if explicit_path is not None:
53
+ path, table = _load_explicit(explicit_path)
54
+ else:
55
+ discovered = _discover(start_dir)
56
+ if discovered is None:
57
+ return Config()
58
+ path, table = discovered
59
+ return _build_config(table, path)
60
+
61
+
62
+ def _load_explicit(path: Path) -> tuple[Path, dict[str, object]]:
63
+ """Load a config table from an explicitly named file.
64
+
65
+ :param path: The file named by ``--config``.
66
+ :returns: The path and its config table.
67
+ :raises ConfigError: If the file is missing, or a ``pyproject.toml`` lacks a
68
+ ``[tool.markwright]`` table.
69
+ """
70
+ if not path.is_file():
71
+ raise ConfigError(f"config file not found: {path}")
72
+ parsed = _read_toml(path)
73
+ if path.name == "pyproject.toml":
74
+ table = _pyproject_markwright_table(parsed)
75
+ if table is None:
76
+ raise ConfigError(f"no [tool.markwright] table in {path}")
77
+ return path, table
78
+ return path, parsed
79
+
80
+
81
+ def _discover(start_dir: Path) -> tuple[Path, dict[str, object]] | None:
82
+ """Walk up from ``start_dir`` to the filesystem root looking for a config file.
83
+
84
+ In each directory a ``markwright.toml`` wins over a ``pyproject.toml`` that carries a
85
+ ``[tool.markwright]`` table. The first directory with either stops the search.
86
+
87
+ :param start_dir: The directory to begin the walk from.
88
+ :returns: The winning path and its config table, or ``None`` if none is found.
89
+ :raises ConfigError: If a candidate file is present but unparseable.
90
+ """
91
+ for directory in [start_dir, *start_dir.parents]:
92
+ standalone = directory / "markwright.toml"
93
+ if standalone.is_file():
94
+ return standalone, _read_toml(standalone)
95
+ pyproject = directory / "pyproject.toml"
96
+ if pyproject.is_file():
97
+ table = _pyproject_markwright_table(_read_toml(pyproject))
98
+ if table is not None:
99
+ return pyproject, table
100
+ return None
101
+
102
+
103
+ def _read_toml(path: Path) -> dict[str, object]:
104
+ """Parse a TOML file, converting parse failures into a clear boundary error.
105
+
106
+ :param path: The TOML file to read.
107
+ :returns: The parsed top-level table.
108
+ :raises ConfigError: If the file is not valid TOML.
109
+ """
110
+ try:
111
+ with path.open("rb") as handle:
112
+ parsed: dict[str, object] = tomllib.load(handle)
113
+ except tomllib.TOMLDecodeError as decode_error:
114
+ raise ConfigError(f"could not parse {path}: {decode_error}") from decode_error
115
+ return parsed
116
+
117
+
118
+ def _pyproject_markwright_table(parsed: Mapping[str, object]) -> dict[str, object] | None:
119
+ """Extract the ``[tool.markwright]`` table from a parsed ``pyproject.toml``.
120
+
121
+ :param parsed: The parsed ``pyproject.toml`` top-level table.
122
+ :returns: The markwright config table, or ``None`` if the ``[tool.markwright]`` path is absent.
123
+ """
124
+ tool_section = parsed.get("tool")
125
+ if not isinstance(tool_section, dict):
126
+ return None
127
+ markwright_section = tool_section.get("markwright")
128
+ if not isinstance(markwright_section, dict):
129
+ return None
130
+ return markwright_section
131
+
132
+
133
+ def _build_config(table: Mapping[str, object], path: Path) -> Config:
134
+ """Validate a raw config table and resolve it into a :class:`Config`.
135
+
136
+ :param table: The parsed config table (from a standalone file or a ``[tool.markwright]`` table).
137
+ :param path: The source path, recorded for the source map.
138
+ :returns: The resolved config.
139
+ :raises ConfigError: If any key, type, or extension name is invalid.
140
+ """
141
+ _reject_unknown_keys(table, path)
142
+ enable = _selection_list(table, "enable", path)
143
+ disable = _selection_list(table, "disable", path)
144
+ if enable is not None and disable is not None:
145
+ raise ConfigError(f"{path}: 'enable' and 'disable' are mutually exclusive")
146
+ return Config(
147
+ enable=enable,
148
+ disable=disable if disable is not None else (),
149
+ options=_extension_options(table, path),
150
+ warn=_warn_value(table, path),
151
+ source_path=path,
152
+ sources={key: str(path) for key in table},
153
+ )
154
+
155
+
156
+ def _reject_unknown_keys(table: Mapping[str, object], path: Path) -> None:
157
+ """Reject any top-level key that is neither a recognized setting nor an extension name.
158
+
159
+ :param table: The config table.
160
+ :param path: The source path, for the error message.
161
+ :raises ConfigError: On the first unrecognized key.
162
+ """
163
+ for key in table:
164
+ if key not in _TOP_LEVEL_KEYS and key not in EXTENSION_NAMES:
165
+ raise ConfigError(f"{path}: unknown configuration key: {key!r}")
166
+
167
+
168
+ def _selection_list(table: Mapping[str, object], key: str, path: Path) -> tuple[str, ...] | None:
169
+ """Read and validate an ``enable`` or ``disable`` list of extension names.
170
+
171
+ :param table: The config table.
172
+ :param key: Either ``"enable"`` or ``"disable"``.
173
+ :param path: The source path, for error messages.
174
+ :returns: The names as a tuple, or ``None`` if the key is absent.
175
+ :raises ConfigError: If the value is not a list of known extension names.
176
+ """
177
+ if key not in table:
178
+ return None
179
+ value = table[key]
180
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
181
+ raise ConfigError(f"{path}: '{key}' must be a list of extension names")
182
+ for name in value:
183
+ if name not in EXTENSION_NAMES:
184
+ raise ConfigError(f"{path}: unknown extension in '{key}': {name!r}")
185
+ return tuple(value)
186
+
187
+
188
+ def _warn_value(table: Mapping[str, object], path: Path) -> bool:
189
+ """Read and validate the boolean ``warn`` default.
190
+
191
+ :param table: The config table.
192
+ :param path: The source path, for the error message.
193
+ :returns: The ``warn`` value, defaulting to ``False`` when absent.
194
+ :raises ConfigError: If ``warn`` is present but not a boolean.
195
+ """
196
+ if "warn" not in table:
197
+ return False
198
+ value = table["warn"]
199
+ if not isinstance(value, bool):
200
+ raise ConfigError(f"{path}: 'warn' must be a boolean")
201
+ return value
202
+
203
+
204
+ def _extension_options(table: Mapping[str, object], path: Path) -> dict[str, dict[str, object]]:
205
+ """Collect the per-extension option tables from the config.
206
+
207
+ :param table: The config table.
208
+ :param path: The source path, for error messages.
209
+ :returns: Option dicts keyed by extension name, in registry order.
210
+ :raises ConfigError: If an extension key is present but not a table.
211
+ """
212
+ options: dict[str, dict[str, object]] = {}
213
+ for name in EXTENSION_NAMES:
214
+ if name not in table:
215
+ continue
216
+ extension_table = table[name]
217
+ if not isinstance(extension_table, dict):
218
+ raise ConfigError(f"{path}: '{name}' must be a table of options")
219
+ options[name] = extension_table
220
+ return options
@@ -30,6 +30,7 @@ from __future__ import annotations
30
30
  import html
31
31
  import json
32
32
  import re
33
+ from collections.abc import Mapping
33
34
 
34
35
  from markdown import Markdown
35
36
  from markdown.extensions import Extension
@@ -267,16 +268,32 @@ def _expand_lines(lines: list[str], allowed_environments: list[str] | None) -> l
267
268
  return output
268
269
 
269
270
 
270
- def expand_source(text: str) -> str:
271
+ def expand_source(text: str, options: Mapping[str, object] | None = None) -> str:
271
272
  """Extract fence directives and emit mw-fence marker comments in raw source.
272
273
 
273
- Used by the ``mw pre`` CLI stage. Environments are not restricted (the
274
- allow-list is an in-process configuration option only).
274
+ Used by the ``mw pre`` CLI stage. When ``options`` carries an
275
+ ``allowed_environments`` list, ``[environment ...]`` directives outside it are left
276
+ unextracted, matching the in-process ``allowed_environments`` config option.
275
277
 
276
278
  :param text: The source text.
279
+ :param options: The fence option table from config, or ``None`` for no restriction.
277
280
  :returns: The text with directives replaced by mw-fence marker comments.
278
281
  """
279
- return "\n".join(_expand_lines(text.split("\n"), None))
282
+ return "\n".join(_expand_lines(text.split("\n"), _allowed_environments(options)))
283
+
284
+
285
+ def _allowed_environments(options: Mapping[str, object] | None) -> list[str] | None:
286
+ """Read the ``allowed_environments`` list from a fence option table.
287
+
288
+ :param options: The fence option table, or ``None``.
289
+ :returns: The allowed environment names, or ``None`` to allow every environment.
290
+ """
291
+ if options is None:
292
+ return None
293
+ value = options.get("allowed_environments")
294
+ if isinstance(value, list):
295
+ return [str(item) for item in value]
296
+ return None
280
297
 
281
298
 
282
299
  class FencePreprocessor(Preprocessor):
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import re
7
7
  import xml.etree.ElementTree as etree
8
+ from collections.abc import Mapping
8
9
 
9
10
  from markdown import Markdown
10
11
  from markdown.extensions import Extension
@@ -98,7 +99,7 @@ def _highlight_prose(segment: str) -> str:
98
99
  return _PROSE_HIGHLIGHT_RE.sub(r"<mark>\1</mark>", segment)
99
100
 
100
101
 
101
- def expand_source(text: str) -> str:
102
+ def expand_source(text: str, options: Mapping[str, object] | None = None) -> str:
102
103
  """Wrap prose highlight markers in ``<mark>`` outside code regions.
103
104
 
104
105
  The source-stage transform for ``mw pre``. Fenced code blocks, inline code
@@ -107,6 +108,7 @@ def expand_source(text: str) -> str:
107
108
  post stage.
108
109
 
109
110
  :param text: Raw Markdown source.
111
+ :param options: Ignored; accepted so the registry calls every pre stage with one signature.
110
112
  :returns: Source with unescaped prose ``<^>...<^>`` markers wrapped in
111
113
  ``<mark>``.
112
114
  """
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import html
7
7
  import re
8
+ from collections.abc import Mapping
8
9
 
9
10
  from markdown import Markdown
10
11
  from markdown.extensions import Extension
@@ -43,13 +44,14 @@ def _render_match(line: str) -> str | None:
43
44
  return _build_compare_html(left_url, right_url, height, width)
44
45
 
45
46
 
46
- def expand_source(text: str) -> str:
47
+ def expand_source(text: str, options: Mapping[str, object] | None = None) -> str:
47
48
  """Expand standalone compare embed lines to HTML in raw source.
48
49
 
49
50
  Used by the ``mw pre`` CLI stage. Emits the HTML inline without any
50
51
  Python-Markdown stash placeholder.
51
52
 
52
53
  :param text: The source text.
54
+ :param options: Ignored; accepted so the registry calls every pre stage with one signature.
53
55
  :returns: The text with standalone compare embeds replaced by HTML.
54
56
  """
55
57
  return "\n".join(_render_match(line) or line for line in text.split("\n"))
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import html
7
7
  import re
8
+ from collections.abc import Mapping
8
9
 
9
10
  from markdown import Markdown
10
11
  from markdown.extensions import Extension
@@ -102,13 +103,14 @@ def _render_match(line: str) -> str | None:
102
103
  )
103
104
 
104
105
 
105
- def expand_source(text: str) -> str:
106
+ def expand_source(text: str, options: Mapping[str, object] | None = None) -> str:
106
107
  """Expand standalone Instagram embed lines to embed HTML in raw source.
107
108
 
108
109
  Used by the ``mw pre`` CLI stage. Unlike the preprocessor, this emits the
109
110
  HTML inline without any Python-Markdown stash placeholder.
110
111
 
111
112
  :param text: The source text.
113
+ :param options: Ignored; accepted so the registry calls every pre stage with one signature.
112
114
  :returns: The text with standalone Instagram embeds replaced by HTML.
113
115
  """
114
116
  return "\n".join(_render_match(line) or line for line in text.split("\n"))
@@ -3,7 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- from collections.abc import Callable
6
+ from collections.abc import Callable, Mapping
7
7
  from typing import TypedDict
8
8
 
9
9
  from markwright.codepen import apply_html as codepen_post
@@ -20,7 +20,7 @@ from markwright.twitter import apply_html as twitter_post
20
20
  from markwright.twitter import expand_source as twitter_pre
21
21
  from markwright.youtube import expand_source as youtube_pre
22
22
 
23
- PreFn = Callable[[str], str]
23
+ PreFn = Callable[[str, Mapping[str, object] | None], str]
24
24
  PostFn = Callable[[str, list[str] | None], str]
25
25
 
26
26
 
@@ -87,17 +87,19 @@ def _ordered(
87
87
  return sorted(selected, key=lambda name: get_priority(REGISTRY[name]), reverse=True)
88
88
 
89
89
 
90
- def run_pre(text: str, names: list[str]) -> str:
90
+ def run_pre(text: str, names: list[str], options: Mapping[str, Mapping[str, object]] | None = None) -> str:
91
91
  """Apply each selected pre-stage transform to ``text`` in descending priority order.
92
92
 
93
93
  :param text: Markdown source text.
94
94
  :param names: Selected extension names.
95
+ :param options: Per-extension option tables keyed by extension name; each stage receives
96
+ its own table, or ``None`` when it has none.
95
97
  :returns: Source text after every selected pre stage has run.
96
98
  """
97
99
  for name in _ordered(names, lambda spec: spec["pre"], lambda spec: spec["pre_priority"]):
98
100
  pre_fn = REGISTRY[name]["pre"]
99
101
  assert pre_fn is not None
100
- text = pre_fn(text)
102
+ text = pre_fn(text, options.get(name) if options is not None else None)
101
103
  return text
102
104
 
103
105
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import html
7
7
  import re
8
+ from collections.abc import Mapping
8
9
 
9
10
  from markdown import Markdown
10
11
  from markdown.extensions import Extension
@@ -64,13 +65,14 @@ def _render_match(line: str) -> str | None:
64
65
  return _build_slideshow_html(urls, height, width)
65
66
 
66
67
 
67
- def expand_source(text: str) -> str:
68
+ def expand_source(text: str, options: Mapping[str, object] | None = None) -> str:
68
69
  """Expand standalone slideshow embed lines to HTML in raw source.
69
70
 
70
71
  Used by the ``mw pre`` CLI stage. Emits the HTML inline without any
71
72
  Python-Markdown stash placeholder.
72
73
 
73
74
  :param text: The source text.
75
+ :param options: Ignored; accepted so the registry calls every pre stage with one signature.
74
76
  :returns: The text with standalone slideshow embeds replaced by HTML.
75
77
  """
76
78
  return "\n".join(_render_match(line) or line for line in text.split("\n"))
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import html
7
7
  import re
8
+ from collections.abc import Mapping
8
9
 
9
10
  from markdown import Markdown
10
11
  from markdown.extensions import Extension
@@ -96,13 +97,14 @@ def _render_match(line: str) -> str | None:
96
97
  )
97
98
 
98
99
 
99
- def expand_source(text: str) -> str:
100
+ def expand_source(text: str, options: Mapping[str, object] | None = None) -> str:
100
101
  """Expand standalone Twitter embed lines to embed HTML in raw source.
101
102
 
102
103
  Used by the ``mw pre`` CLI stage. Unlike the preprocessor, this emits the
103
104
  HTML inline without any Python-Markdown stash placeholder.
104
105
 
105
106
  :param text: The source text.
107
+ :param options: Ignored; accepted so the registry calls every pre stage with one signature.
106
108
  :returns: The text with standalone Twitter embeds replaced by HTML.
107
109
  """
108
110
  return "\n".join(_render_match(line) or line for line in text.split("\n"))
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import re
7
7
  import urllib.parse
8
+ from collections.abc import Mapping
8
9
 
9
10
  from markdown import Markdown
10
11
  from markdown.extensions import Extension
@@ -48,13 +49,14 @@ def _render_match(line: str) -> str | None:
48
49
  )
49
50
 
50
51
 
51
- def expand_source(text: str) -> str:
52
+ def expand_source(text: str, options: Mapping[str, object] | None = None) -> str:
52
53
  """Expand standalone YouTube embed lines to iframe HTML in raw source.
53
54
 
54
55
  Used by the ``mw pre`` CLI stage. Unlike the preprocessor, this emits the
55
56
  HTML inline without any Python-Markdown stash placeholder.
56
57
 
57
58
  :param text: The source text.
59
+ :param options: Ignored; accepted so the registry calls every pre stage with one signature.
58
60
  :returns: The text with standalone YouTube embeds replaced by iframe HTML.
59
61
  """
60
62
  return "\n".join(_render_match(line) or line for line in text.split("\n"))
@@ -1,147 +0,0 @@
1
- # ABOUTME: Command-line entry point for the mw markwright pipeline tool.
2
- # Builds the argparse parser and dispatches list/pre/post/render; --version reports the package version.
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import sys
8
- from importlib.metadata import version
9
-
10
- import markdown
11
-
12
- from markwright import registry
13
-
14
-
15
- def _package_version() -> str:
16
- """Return the installed markwright distribution version.
17
-
18
- :returns: The version string for the ``markwright`` distribution.
19
- """
20
- return version("markwright")
21
-
22
-
23
- def build_parser() -> argparse.ArgumentParser:
24
- """Construct the ``mw`` argument parser with its subcommands.
25
-
26
- :returns: A parser exposing ``--version`` and the ``list`` subcommand.
27
- """
28
- parser = argparse.ArgumentParser(prog="mw", description="markwright Markdown pipeline CLI.")
29
- parser.add_argument("--version", action="version", version=f"mw {_package_version()}")
30
- subparsers = parser.add_subparsers(dest="command")
31
- subparsers.add_parser("list", help="List registered extensions and the stages each provides.")
32
- pre_parser = subparsers.add_parser("pre", help="Expand markwright source directives read from stdin.")
33
- _add_selection_flags(pre_parser)
34
- post_parser = subparsers.add_parser("post", help="Post-process rendered HTML read from stdin.")
35
- _add_selection_flags(post_parser)
36
- post_parser.add_argument("--warn", action="store_true", help="Report skipped markers to stderr.")
37
- render_parser = subparsers.add_parser("render", help="Render Markdown from stdin to final HTML.")
38
- _add_selection_flags(render_parser)
39
- return parser
40
-
41
-
42
- def _add_selection_flags(subparser: argparse.ArgumentParser) -> None:
43
- """Add the shared ``--use`` and ``--exclude`` selection flags to a subparser.
44
-
45
- :param subparser: The subcommand parser to extend.
46
- """
47
- subparser.add_argument("--use", action="append", default=[], help="Restrict to the named extension (repeatable).")
48
- subparser.add_argument("--exclude", action="append", default=[], help="Drop the named extension (repeatable).")
49
-
50
-
51
- def _run_list() -> int:
52
- """Print each registered extension and its available stages.
53
-
54
- :returns: Always ``0``.
55
- """
56
- for name, stages in registry.describe():
57
- print(f"{name}: {', '.join(stages)}")
58
- return 0
59
-
60
-
61
- def _resolve_selection(args: argparse.Namespace) -> list[str] | None:
62
- """Resolve the active extension names, reporting unknown names to stderr.
63
-
64
- :param args: Parsed arguments carrying ``use`` and ``exclude`` lists.
65
- :returns: Selected extension names, or ``None`` if a name is unknown (the
66
- caller should then return exit code ``2``).
67
- """
68
- try:
69
- return registry.select_extensions(args.use, args.exclude)
70
- except ValueError as selection_error:
71
- print(selection_error, file=sys.stderr)
72
- return None
73
-
74
-
75
- def _run_pre(args: argparse.Namespace) -> int:
76
- """Expand source directives from stdin and write the result to stdout.
77
-
78
- :param args: Parsed arguments carrying ``use`` and ``exclude``.
79
- :returns: ``0`` on success, ``2`` if a selected extension name is unknown.
80
- """
81
- names = _resolve_selection(args)
82
- if names is None:
83
- return 2
84
- sys.stdout.write(registry.run_pre(sys.stdin.read(), names))
85
- return 0
86
-
87
-
88
- def _run_post(args: argparse.Namespace) -> int:
89
- """Post-process HTML from stdin and write the result to stdout.
90
-
91
- :param args: Parsed arguments carrying ``use``, ``exclude``, and ``warn``.
92
- :returns: ``0`` on success, ``2`` if a selected extension name is unknown.
93
- """
94
- names = _resolve_selection(args)
95
- if names is None:
96
- return 2
97
- warnings: list[str] | None = [] if args.warn else None
98
- rendered_html = registry.run_post(sys.stdin.read(), names, warnings)
99
- sys.stdout.write(rendered_html)
100
- if warnings is not None:
101
- for warning in warnings:
102
- print(warning, file=sys.stderr)
103
- return 0
104
-
105
-
106
- def _run_render(args: argparse.Namespace) -> int:
107
- """Render Markdown from stdin to final HTML using the in-process stack.
108
-
109
- Builds a ``markdown.Markdown`` instance configured with ``pymdownx.superfences``
110
- and ``pymdownx.highlight`` plus the selected ``markwright.*`` extensions, mirroring
111
- the site stack so fence and highlight render correctly.
112
-
113
- :param args: Parsed arguments carrying ``use`` and ``exclude``.
114
- :returns: ``0`` on success, ``2`` if a selected extension name is unknown.
115
- """
116
- names = _resolve_selection(args)
117
- if names is None:
118
- return 2
119
- instance = markdown.Markdown(
120
- extensions=["pymdownx.superfences", "pymdownx.highlight", *(f"markwright.{name}" for name in names)],
121
- extension_configs={"pymdownx.highlight": {"pygments_lang_class": True}},
122
- )
123
- sys.stdout.write(instance.convert(sys.stdin.read()))
124
- return 0
125
-
126
-
127
- def main(argv: list[str] | None = None) -> int:
128
- """Parse ``argv`` and dispatch to the selected subcommand.
129
-
130
- :param argv: Argument vector, or ``None`` to read from ``sys.argv``.
131
- :returns: Process exit code (``0`` success, ``2`` usage error).
132
- """
133
- parser = build_parser()
134
- try:
135
- args = parser.parse_args(argv)
136
- except SystemExit as exit_error:
137
- return exit_error.code if isinstance(exit_error.code, int) else 2
138
- if args.command == "list":
139
- return _run_list()
140
- if args.command == "pre":
141
- return _run_pre(args)
142
- if args.command == "post":
143
- return _run_post(args)
144
- if args.command == "render":
145
- return _run_render(args)
146
- parser.print_usage()
147
- return 2
File without changes
File without changes