markwright 0.1.0__tar.gz → 0.3.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.
- markwright-0.3.0/.gitignore +25 -0
- markwright-0.3.0/CHANGELOG.md +91 -0
- {markwright-0.1.0 → markwright-0.3.0}/PKG-INFO +42 -33
- {markwright-0.1.0 → markwright-0.3.0}/README.md +32 -23
- markwright-0.1.0/pyproject.toml.orig → markwright-0.3.0/pyproject.toml +14 -9
- markwright-0.3.0/src/markwright/__init__.py +17 -0
- markwright-0.3.0/src/markwright/_diagnostics.py +20 -0
- markwright-0.3.0/src/markwright/_embed_load.py +82 -0
- markwright-0.3.0/src/markwright/_fences.py +107 -0
- markwright-0.3.0/src/markwright/_html.py +225 -0
- markwright-0.3.0/src/markwright/cli.py +243 -0
- markwright-0.3.0/src/markwright/codepen.py +270 -0
- markwright-0.3.0/src/markwright/config.py +285 -0
- markwright-0.3.0/src/markwright/fence.py +553 -0
- markwright-0.3.0/src/markwright/highlight.py +220 -0
- {markwright-0.1.0 → markwright-0.3.0}/src/markwright/image_compare.py +22 -28
- markwright-0.3.0/src/markwright/instagram.py +253 -0
- markwright-0.3.0/src/markwright/registry.py +153 -0
- {markwright-0.1.0 → markwright-0.3.0}/src/markwright/slideshow.py +28 -33
- markwright-0.3.0/src/markwright/twitter.py +246 -0
- {markwright-0.1.0 → markwright-0.3.0}/src/markwright/youtube.py +55 -16
- markwright-0.3.0/tests/integration/test_clean_install.py +117 -0
- markwright-0.3.0/tests/integration/test_hugo_pipeline.py +165 -0
- markwright-0.3.0/tests/test_cli.py +620 -0
- markwright-0.3.0/tests/test_codepen.py +171 -0
- markwright-0.3.0/tests/test_config.py +385 -0
- markwright-0.3.0/tests/test_docs_tokens.py +37 -0
- markwright-0.3.0/tests/test_fence.py +623 -0
- markwright-0.3.0/tests/test_highlight.py +272 -0
- markwright-0.3.0/tests/test_image_compare.py +152 -0
- markwright-0.3.0/tests/test_instagram.py +206 -0
- markwright-0.3.0/tests/test_load.py +205 -0
- markwright-0.3.0/tests/test_packaging.py +53 -0
- markwright-0.3.0/tests/test_registry.py +65 -0
- markwright-0.3.0/tests/test_roundtrip.py +304 -0
- markwright-0.3.0/tests/test_slideshow.py +177 -0
- markwright-0.3.0/tests/test_twitter.py +188 -0
- markwright-0.3.0/tests/test_youtube.py +140 -0
- markwright-0.1.0/pyproject.toml +0 -81
- markwright-0.1.0/src/markwright/__init__.py +0 -0
- markwright-0.1.0/src/markwright/_util.py +0 -31
- markwright-0.1.0/src/markwright/cli.py +0 -147
- markwright-0.1.0/src/markwright/codepen.py +0 -206
- markwright-0.1.0/src/markwright/fence.py +0 -483
- markwright-0.1.0/src/markwright/highlight.py +0 -189
- markwright-0.1.0/src/markwright/instagram.py +0 -193
- markwright-0.1.0/src/markwright/registry.py +0 -133
- markwright-0.1.0/src/markwright/twitter.py +0 -187
- {markwright-0.1.0 → markwright-0.3.0}/LICENSE +0 -0
- {markwright-0.1.0 → markwright-0.3.0}/LICENSE-Apache-2.0 +0 -0
- {markwright-0.1.0 → markwright-0.3.0}/NOTICE +0 -0
- {markwright-0.1.0 → markwright-0.3.0}/src/markwright/py.typed +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
commit-msg.md
|
|
2
|
+
goal.md
|
|
3
|
+
.ai-sessions/implementation-notes.md
|
|
4
|
+
|
|
5
|
+
# Python-generated files
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[oc]
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
wheels/
|
|
11
|
+
*.egg-info
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv
|
|
15
|
+
|
|
16
|
+
# MkDocs build output
|
|
17
|
+
site/
|
|
18
|
+
|
|
19
|
+
# Coverage artifacts
|
|
20
|
+
.coverage
|
|
21
|
+
*.cover
|
|
22
|
+
htmlcov/
|
|
23
|
+
|
|
24
|
+
# Hypothesis example database
|
|
25
|
+
.hypothesis/
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to markwright are recorded here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.3.0] - 2026-10-11
|
|
7
|
+
|
|
8
|
+
Two opt-in features (a config API for host tools and click-to-load embeds), one rule for every config list, a round of correctness fixes, and Python 3.15 support.
|
|
9
|
+
With default settings, output is unchanged for documents 0.2.0 already rendered correctly; the differences are the fixes listed below.
|
|
10
|
+
|
|
11
|
+
### Upgrading from 0.2.0
|
|
12
|
+
|
|
13
|
+
- `enable = []` now runs no extensions, and `fence.allowed_environments = []` now allows no environments; both used to mean "everything".
|
|
14
|
+
Both now emit a `ConfigWarning`.
|
|
15
|
+
Omit the key to get the old behavior.
|
|
16
|
+
- Config validation is stricter: an option key an extension does not take, a `load` value it does not support, or a non-list `fence.allowed_environments` is now a `ConfigError` (`mw` exits 2 with an `mw: error:` line) instead of being ignored.
|
|
17
|
+
- Python API renames, if you call the stage functions directly: the `warnings` parameter of `apply_html` and `registry.run_post` is now `skip_reasons`, and `highlight.apply_html`'s first parameter is now `rendered_html`.
|
|
18
|
+
- Removed APIs: `registry.select_extensions`, `registry.OPTION_CHECKS`, `registry.LOAD_MODES`, and the `*_SIGNATURE` constants (see Removed).
|
|
19
|
+
- New runtime dependency `pydantic>=2.14.1`, and the `pymdown-extensions` floor rises to 11.0.1.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- A public config API for host tools: `config_from_mapping(table, source)` validates an already-parsed markwright table with the same rules as `markwright.toml`, `selected_extensions(config)` applies `enable` and `disable`, and `extension_configs(config, names)` builds the `extension_configs` argument for `markdown.Markdown`.
|
|
24
|
+
The package root re-exports these with `Config`, `ConfigError`, `ConfigWarning`, and `OptionError`.
|
|
25
|
+
`mw render` uses the same functions.
|
|
26
|
+
- A `load` option on the `youtube`, `codepen`, `twitter`, and `instagram` embeds.
|
|
27
|
+
`eager` is the default and unchanged.
|
|
28
|
+
`click` emits a versioned click-to-load preview with the live markup inside a `<template>`, so nothing reaches the provider until a host script loads it.
|
|
29
|
+
`lazy` (YouTube only) adds `loading="lazy"` to the iframe.
|
|
30
|
+
Provider scripts are injected only when at least one embed of that kind on the page is eager.
|
|
31
|
+
- A Click-to-Load docs page with the markup contract, the provider processor calls (checked against the providers' live scripts), and a sample host script.
|
|
32
|
+
- `mw config` reports the resolved `load` for each embed and where it came from.
|
|
33
|
+
- `markwright.ConfigWarning`, emitted when a setting turns a feature into a no-op (an empty selection or an empty `allowed_environments`).
|
|
34
|
+
It never raises unless you escalate it, for example with `warnings.simplefilter("error", ConfigWarning)`.
|
|
35
|
+
`mw` prints it as one `mw: warning:` line on stderr.
|
|
36
|
+
- `markwright.OptionError`, a `ValueError` subclass raised for an invalid extension option value.
|
|
37
|
+
- Config Rules in the configuration docs: omitting a key means its default, a list names exactly what it holds, a no-op setting warns, and every option is validated.
|
|
38
|
+
- Python 3.15 support, tested in CI.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- `enable = []` runs no extensions and `fence.allowed_environments = []` allows no environments, each with a `ConfigWarning`; both used to mean "everything" (#13).
|
|
43
|
+
- Config validation runs each extension's own option checks and rejects option keys an extension does not take; an unknown key used to pass through and crash `mw render` with a `KeyError` traceback.
|
|
44
|
+
- `mw` reports config problems as one `mw: error:` line with exit code 2, including a `ConfigWarning` escalated with `python -W error`, and prints only markwright's own warnings as `mw: warning:` lines.
|
|
45
|
+
- A config value of null (a YAML key with no value) counts as omitted.
|
|
46
|
+
- `apply_html` and `registry.run_post` take per-extension options, mirroring `expand_source` and `run_pre`, and name their skip-reason list `skip_reasons`.
|
|
47
|
+
- The post stages read rendered HTML with the stdlib `html.parser` instead of regex and edit only the spans they change.
|
|
48
|
+
- Fence directives and prose highlights find fenced code by CommonMark's rules, through one scanner shared by both pre stages.
|
|
49
|
+
- `registry.REGISTRY` holds pydantic `StageSpec` models, and the embed flag settings are pydantic models.
|
|
50
|
+
|
|
51
|
+
### Removed
|
|
52
|
+
|
|
53
|
+
- `registry.select_extensions`; selection lives in `config.selected_extensions`.
|
|
54
|
+
- `registry.OPTION_CHECKS` and `registry.LOAD_MODES`; each `StageSpec` carries its option checker, and `LOAD_MODES` stays on each embed module.
|
|
55
|
+
- The `CODEPEN_SIGNATURE`, `TWITTER_SIGNATURE`, and `INSTAGRAM_SIGNATURE` constants, replaced by `*_EMBED_CLASS` (the bare class name).
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- Fence directives apply to fences indented up to three spaces and to fences inside list items; they used to render as literal code text.
|
|
60
|
+
- A fence marker whose JSON is valid but the wrong shape (`[1]`, `null`, a numeric `label`) is skipped with a warning instead of crashing the post stage with `AttributeError`.
|
|
61
|
+
- Each fence marker styles only its own code block, so a marker with no block can no longer style the next marker's.
|
|
62
|
+
- `fence.label_class` and `secondary_label_class` apply in `mw post`; the pipeline used to drop them while `mw render` honored them.
|
|
63
|
+
- The fence extension reads its options when it registers, so a `setConfig` call after construction applies.
|
|
64
|
+
- A highlight marker inside an HTML attribute (for example `title="<^>"`) is no longer wrapped in `<mark>`, which produced broken markup.
|
|
65
|
+
- A `<^>` marker inside a fence nested in a longer fence is no longer highlighted by `mw pre`, and inline code spans of two or more backticks are skipped like single-backtick ones.
|
|
66
|
+
- An unclosed fence is no longer treated as code by the pre stages, matching `pymdownx.superfences`.
|
|
67
|
+
- An empty extension selection (from `enable = []`, a `disable` listing every extension, or `--exclude`) warns with its cause instead of passing input through silently.
|
|
68
|
+
|
|
69
|
+
### Security
|
|
70
|
+
|
|
71
|
+
- Requires `pymdown-extensions>=11.0.1` (was `>=10.5`), the first release without PYSEC-2026-3654.
|
|
72
|
+
|
|
73
|
+
## [0.2.0] - 2026-09-23
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
- The config system: `markwright.toml` or `[tool.markwright]` discovery, `enable`/`disable`, per-extension options, `warn`, and the `mw config` command.
|
|
78
|
+
|
|
79
|
+
### Removed
|
|
80
|
+
|
|
81
|
+
- The `mw --use` flag; its allowlist lives in config as `enable`.
|
|
82
|
+
|
|
83
|
+
## [0.1.0] - 2026-09-15
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
|
|
87
|
+
- First release: the highlight, fence, YouTube, CodePen, Twitter, Instagram, slideshow, and image compare extensions, and the `mw` pipeline CLI.
|
|
88
|
+
|
|
89
|
+
[0.3.0]: https://github.com/MasonEgger/markwright/compare/v0.2.0...v0.3.0
|
|
90
|
+
[0.2.0]: https://github.com/MasonEgger/markwright/compare/v0.1.0...v0.2.0
|
|
91
|
+
[0.1.0]: https://github.com/MasonEgger/markwright/releases/tag/v0.1.0
|
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: markwright
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python-Markdown extensions ported from DigitalOcean's do-markdownit
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Project-URL: Homepage, https://github.com/MasonEgger/markwright
|
|
6
|
+
Project-URL: Repository, https://github.com/MasonEgger/markwright
|
|
7
|
+
Project-URL: Documentation, https://masonegger.github.io/markwright/
|
|
7
8
|
Author-email: Mason Egger <mason@masonegger.com>
|
|
8
9
|
License-Expression: MIT
|
|
9
10
|
License-File: LICENSE
|
|
10
11
|
License-File: LICENSE-Apache-2.0
|
|
11
12
|
License-File: NOTICE
|
|
12
|
-
|
|
13
|
+
Keywords: do-markdownit,hugo,markdown,mkdocs,python-markdown
|
|
13
14
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
18
20
|
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
19
|
-
Requires-Dist: markdown>=3.4
|
|
20
|
-
Requires-Dist: pymdown-extensions>=10.5
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
Requires-Dist: markdown>=3.4
|
|
23
|
+
Requires-Dist: pydantic>=2.14.1
|
|
24
|
+
Requires-Dist: pymdown-extensions>=11.0.1
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
|
|
27
27
|
# markwright
|
|
28
28
|
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
|
@@ -47,6 +47,10 @@ Each extension is standalone and can be loaded on its own or alongside the other
|
|
|
47
47
|
- **Slideshow** builds an image slideshow with navigation arrows.
|
|
48
48
|
- **Image Compare** renders two images with a draggable before/after slider.
|
|
49
49
|
|
|
50
|
+
The embeds that contact a third party (YouTube, CodePen, Twitter, Instagram) take a `load` option.
|
|
51
|
+
`load = "click"` renders an inert preview so nothing reaches the provider until the reader asks; see [Click-to-Load](https://masonegger.github.io/markwright/click-to-load/).
|
|
52
|
+
A host tool can validate a markwright config table from its own config file with `markwright.config_from_mapping`; see [Configuration](https://masonegger.github.io/markwright/config/).
|
|
53
|
+
|
|
50
54
|
## Installation
|
|
51
55
|
|
|
52
56
|
```bash
|
|
@@ -98,18 +102,20 @@ The extensions work with Python-Markdown directly, so you can use them in any Py
|
|
|
98
102
|
```python
|
|
99
103
|
import markdown
|
|
100
104
|
|
|
101
|
-
md = markdown.Markdown(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
105
|
+
md = markdown.Markdown(
|
|
106
|
+
extensions=[
|
|
107
|
+
"pymdownx.superfences",
|
|
108
|
+
"pymdownx.highlight",
|
|
109
|
+
"markwright.highlight",
|
|
110
|
+
"markwright.fence",
|
|
111
|
+
"markwright.youtube",
|
|
112
|
+
"markwright.codepen",
|
|
113
|
+
"markwright.twitter",
|
|
114
|
+
"markwright.instagram",
|
|
115
|
+
"markwright.slideshow",
|
|
116
|
+
"markwright.image_compare",
|
|
117
|
+
]
|
|
118
|
+
)
|
|
113
119
|
|
|
114
120
|
html = md.convert("This has a <^>highlighted word<^> in it.")
|
|
115
121
|
print(html)
|
|
@@ -139,8 +145,12 @@ mw pre < in.md | your-renderer | mw post > out.html
|
|
|
139
145
|
- `mw pre` expands the embeds and extracts fence directives into an `mw-fence` comment.
|
|
140
146
|
- `mw post` applies the fence styling, resolves the highlights, and injects each embed script once.
|
|
141
147
|
- `mw render` runs the full pipeline in one shot for callers without their own renderer.
|
|
148
|
+
- `mw config` prints the resolved configuration and the source of each value.
|
|
142
149
|
- `mw list` prints every extension and the stages it provides.
|
|
143
150
|
|
|
151
|
+
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.
|
|
152
|
+
See the [configuration docs](https://masonegger.github.io/markwright/config/).
|
|
153
|
+
|
|
144
154
|
Your renderer must pass raw HTML and HTML comments through.
|
|
145
155
|
Hugo is a worked, tested example: see the [Hugo guide](https://masonegger.github.io/markwright/integrations/hugo/).
|
|
146
156
|
The full command and flag reference is in the [CLI docs](https://masonegger.github.io/markwright/cli/).
|
|
@@ -174,17 +184,16 @@ See the [extension docs](https://masonegger.github.io/markwright/) for every fla
|
|
|
174
184
|
This project uses [`uv`](https://docs.astral.sh/uv/) and [`just`](https://github.com/casey/just).
|
|
175
185
|
|
|
176
186
|
```bash
|
|
177
|
-
just
|
|
178
|
-
just
|
|
179
|
-
just test
|
|
180
|
-
just
|
|
181
|
-
just
|
|
182
|
-
just check # test + lint + typecheck
|
|
187
|
+
just check # the gate: ruff check, ruff format --check, mypy strict, pytest
|
|
188
|
+
just fmt # format and auto-fix
|
|
189
|
+
just test # pytest (integration tests skip without hugo on PATH)
|
|
190
|
+
just test-all # pytest across Python 3.11 through 3.15
|
|
191
|
+
just test-integration # only the end-to-end Hugo and clean-install tests
|
|
183
192
|
just docs-serve # serve the docs site at localhost:8000
|
|
184
193
|
```
|
|
185
194
|
|
|
186
195
|
`just check` must pass before a change is complete.
|
|
187
|
-
|
|
196
|
+
Coverage is reported on every run.
|
|
188
197
|
|
|
189
198
|
## License
|
|
190
199
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# markwright
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
|
|
@@ -21,6 +21,10 @@ Each extension is standalone and can be loaded on its own or alongside the other
|
|
|
21
21
|
- **Slideshow** builds an image slideshow with navigation arrows.
|
|
22
22
|
- **Image Compare** renders two images with a draggable before/after slider.
|
|
23
23
|
|
|
24
|
+
The embeds that contact a third party (YouTube, CodePen, Twitter, Instagram) take a `load` option.
|
|
25
|
+
`load = "click"` renders an inert preview so nothing reaches the provider until the reader asks; see [Click-to-Load](https://masonegger.github.io/markwright/click-to-load/).
|
|
26
|
+
A host tool can validate a markwright config table from its own config file with `markwright.config_from_mapping`; see [Configuration](https://masonegger.github.io/markwright/config/).
|
|
27
|
+
|
|
24
28
|
## Installation
|
|
25
29
|
|
|
26
30
|
```bash
|
|
@@ -72,18 +76,20 @@ The extensions work with Python-Markdown directly, so you can use them in any Py
|
|
|
72
76
|
```python
|
|
73
77
|
import markdown
|
|
74
78
|
|
|
75
|
-
md = markdown.Markdown(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
79
|
+
md = markdown.Markdown(
|
|
80
|
+
extensions=[
|
|
81
|
+
"pymdownx.superfences",
|
|
82
|
+
"pymdownx.highlight",
|
|
83
|
+
"markwright.highlight",
|
|
84
|
+
"markwright.fence",
|
|
85
|
+
"markwright.youtube",
|
|
86
|
+
"markwright.codepen",
|
|
87
|
+
"markwright.twitter",
|
|
88
|
+
"markwright.instagram",
|
|
89
|
+
"markwright.slideshow",
|
|
90
|
+
"markwright.image_compare",
|
|
91
|
+
]
|
|
92
|
+
)
|
|
87
93
|
|
|
88
94
|
html = md.convert("This has a <^>highlighted word<^> in it.")
|
|
89
95
|
print(html)
|
|
@@ -113,8 +119,12 @@ mw pre < in.md | your-renderer | mw post > out.html
|
|
|
113
119
|
- `mw pre` expands the embeds and extracts fence directives into an `mw-fence` comment.
|
|
114
120
|
- `mw post` applies the fence styling, resolves the highlights, and injects each embed script once.
|
|
115
121
|
- `mw render` runs the full pipeline in one shot for callers without their own renderer.
|
|
122
|
+
- `mw config` prints the resolved configuration and the source of each value.
|
|
116
123
|
- `mw list` prints every extension and the stages it provides.
|
|
117
124
|
|
|
125
|
+
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.
|
|
126
|
+
See the [configuration docs](https://masonegger.github.io/markwright/config/).
|
|
127
|
+
|
|
118
128
|
Your renderer must pass raw HTML and HTML comments through.
|
|
119
129
|
Hugo is a worked, tested example: see the [Hugo guide](https://masonegger.github.io/markwright/integrations/hugo/).
|
|
120
130
|
The full command and flag reference is in the [CLI docs](https://masonegger.github.io/markwright/cli/).
|
|
@@ -148,17 +158,16 @@ See the [extension docs](https://masonegger.github.io/markwright/) for every fla
|
|
|
148
158
|
This project uses [`uv`](https://docs.astral.sh/uv/) and [`just`](https://github.com/casey/just).
|
|
149
159
|
|
|
150
160
|
```bash
|
|
151
|
-
just
|
|
152
|
-
just
|
|
153
|
-
just test
|
|
154
|
-
just
|
|
155
|
-
just
|
|
156
|
-
just check # test + lint + typecheck
|
|
161
|
+
just check # the gate: ruff check, ruff format --check, mypy strict, pytest
|
|
162
|
+
just fmt # format and auto-fix
|
|
163
|
+
just test # pytest (integration tests skip without hugo on PATH)
|
|
164
|
+
just test-all # pytest across Python 3.11 through 3.15
|
|
165
|
+
just test-integration # only the end-to-end Hugo and clean-install tests
|
|
157
166
|
just docs-serve # serve the docs site at localhost:8000
|
|
158
167
|
```
|
|
159
168
|
|
|
160
169
|
`just check` must pass before a change is complete.
|
|
161
|
-
|
|
170
|
+
Coverage is reported on every run.
|
|
162
171
|
|
|
163
172
|
## License
|
|
164
173
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "markwright"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Python-Markdown extensions ported from DigitalOcean's do-markdownit"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -11,16 +11,17 @@ license = "MIT"
|
|
|
11
11
|
license-files = ["LICENSE", "LICENSE-Apache-2.0", "NOTICE"]
|
|
12
12
|
dependencies = [
|
|
13
13
|
"markdown>=3.4",
|
|
14
|
-
"
|
|
14
|
+
"pydantic>=2.14.1",
|
|
15
|
+
"pymdown-extensions>=11.0.1",
|
|
15
16
|
]
|
|
16
17
|
keywords = ["markdown", "python-markdown", "do-markdownit", "mkdocs", "hugo"]
|
|
17
18
|
classifiers = [
|
|
18
|
-
"License :: OSI Approved :: MIT License",
|
|
19
19
|
"Programming Language :: Python :: 3 :: Only",
|
|
20
20
|
"Programming Language :: Python :: 3.11",
|
|
21
21
|
"Programming Language :: Python :: 3.12",
|
|
22
22
|
"Programming Language :: Python :: 3.13",
|
|
23
23
|
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Programming Language :: Python :: 3.15",
|
|
24
25
|
"Topic :: Text Processing :: Markup :: Markdown",
|
|
25
26
|
]
|
|
26
27
|
|
|
@@ -39,27 +40,31 @@ dev = [
|
|
|
39
40
|
"mypy>=1.10",
|
|
40
41
|
"pytest-cov>=7.0",
|
|
41
42
|
"types-markdown>=3.10.2.20260211",
|
|
42
|
-
"mkdocs>=1.6
|
|
43
|
+
"mkdocs>=1.6",
|
|
43
44
|
"mkdocs-material>=9.7.5",
|
|
45
|
+
"hypothesis>=6.169.0",
|
|
44
46
|
]
|
|
45
47
|
|
|
46
48
|
[build-system]
|
|
47
|
-
requires = ["
|
|
48
|
-
build-backend = "
|
|
49
|
+
requires = ["hatchling"]
|
|
50
|
+
build-backend = "hatchling.build"
|
|
51
|
+
|
|
52
|
+
# Hatchling's default sdist is every git-tracked file; ship only the package, its tests, and what licenses it
|
|
53
|
+
[tool.hatch.build.targets.sdist]
|
|
54
|
+
include = ["src/markwright", "tests", "README.md", "CHANGELOG.md", "LICENSE", "LICENSE-Apache-2.0", "NOTICE"]
|
|
49
55
|
|
|
50
56
|
[tool.pytest.ini_options]
|
|
51
57
|
testpaths = ["tests"]
|
|
58
|
+
addopts = "--cov=markwright --cov-branch --cov-report=term-missing"
|
|
52
59
|
markers = [
|
|
53
60
|
"integration: end-to-end tests that require external tools such as Hugo",
|
|
54
61
|
]
|
|
55
62
|
|
|
56
63
|
[tool.ruff]
|
|
57
|
-
target-version = "py311"
|
|
58
64
|
line-length = 120
|
|
59
65
|
|
|
60
66
|
[tool.ruff.lint]
|
|
61
|
-
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
67
|
+
select = ["E", "W", "F", "I", "N", "UP", "B", "SIM", "C4", "TC"]
|
|
62
68
|
|
|
63
69
|
[tool.mypy]
|
|
64
70
|
strict = true
|
|
65
|
-
python_version = "3.11"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ABOUTME: Package root for markwright; re-exports the public config API for host tools.
|
|
2
|
+
# Extensions load by name (markwright.<name>) and need nothing from here.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from markwright._diagnostics import ConfigWarning, OptionError
|
|
7
|
+
from markwright.config import Config, ConfigError, config_from_mapping, extension_configs, selected_extensions
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Config",
|
|
11
|
+
"ConfigError",
|
|
12
|
+
"ConfigWarning",
|
|
13
|
+
"OptionError",
|
|
14
|
+
"config_from_mapping",
|
|
15
|
+
"extension_configs",
|
|
16
|
+
"selected_extensions",
|
|
17
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# ABOUTME: Shared diagnostics: OptionError for invalid extension options, ConfigWarning for no-op settings.
|
|
2
|
+
# A leaf module so config, the extensions, and the CLI can share them without an import cycle through the registry.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConfigWarning(UserWarning):
|
|
8
|
+
"""Emitted when a setting makes a feature do nothing, for example ``enable = []``.
|
|
9
|
+
|
|
10
|
+
Such settings stay legal (they switch markwright off inside a tool chain) but are never silent.
|
|
11
|
+
A host can turn them into errors with ``warnings.simplefilter("error", ConfigWarning)``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OptionError(ValueError):
|
|
16
|
+
"""Raised when an extension option has an invalid value, for example ``load = "sometimes"``.
|
|
17
|
+
|
|
18
|
+
A ``ValueError`` subclass, so callers that catch ``ValueError`` from Python-Markdown keep working.
|
|
19
|
+
Config validation converts it to a :class:`~markwright.config.ConfigError` naming the source.
|
|
20
|
+
"""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# ABOUTME: The load option (eager, lazy, click) shared by the embeds that reach a third party.
|
|
2
|
+
# Validates the option and wraps live markup in the versioned click-to-load preview.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import html
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from markwright._diagnostics import OptionError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
URL_SCHEME_WWW_PREFIX = r"(?:(?:https?:)?//)?(?:www\.)?"
|
|
15
|
+
"""Regex fragment matching an optional ``scheme://`` and an optional ``www.``.
|
|
16
|
+
|
|
17
|
+
Shared by the Twitter and Instagram embed grammars (upstream ``twitter.js`` and ``instagram.js``),
|
|
18
|
+
which both accept a URL prefix ranging from nothing (a bare path) up to a full ``https://www.<host>``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
LOAD_EAGER = "eager"
|
|
22
|
+
LOAD_LAZY = "lazy"
|
|
23
|
+
LOAD_CLICK = "click"
|
|
24
|
+
|
|
25
|
+
# Version of the preview markup hosts code against; bump it on any breaking change to the contract.
|
|
26
|
+
PREVIEW_VERSION = 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_mode(options: Mapping[str, object] | None, allowed: tuple[str, ...]) -> str:
|
|
30
|
+
"""Read and validate the ``load`` option from an embed's option table.
|
|
31
|
+
|
|
32
|
+
:param options: The embed's option table (config table or extension configs), or ``None``.
|
|
33
|
+
:param allowed: The load values this embed supports.
|
|
34
|
+
:returns: The load value, ``"eager"`` when omitted or ``None``.
|
|
35
|
+
:raises OptionError: If ``load`` is not one of ``allowed``.
|
|
36
|
+
"""
|
|
37
|
+
load = (options or {}).get("load")
|
|
38
|
+
if load is None:
|
|
39
|
+
return LOAD_EAGER
|
|
40
|
+
if not isinstance(load, str) or load not in allowed:
|
|
41
|
+
raise OptionError(f"'load' must be one of {', '.join(allowed)}; got {load!r}")
|
|
42
|
+
return load
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def wrap_preview(
|
|
46
|
+
*,
|
|
47
|
+
embed: str,
|
|
48
|
+
load: str,
|
|
49
|
+
content: str,
|
|
50
|
+
script_src: str | None,
|
|
51
|
+
provider: str,
|
|
52
|
+
action: str,
|
|
53
|
+
link_href: str,
|
|
54
|
+
link_text: str,
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Wrap an embed's live markup in the click-to-load preview.
|
|
57
|
+
|
|
58
|
+
The live markup goes inside a ``<template>``, which browsers parse but never render or
|
|
59
|
+
fetch from, so nothing loads until a host script clones it into the page.
|
|
60
|
+
|
|
61
|
+
:param embed: The extension name, for ``data-mw-embed``.
|
|
62
|
+
:param load: The load value, for ``data-mw-load``.
|
|
63
|
+
:param content: The embed's eager markup, without any provider ``<script>``.
|
|
64
|
+
:param script_src: The provider script a host injects after cloning, or ``None``.
|
|
65
|
+
:param provider: Text naming what the embed is, for example ``"YouTube video"``.
|
|
66
|
+
:param action: The load button text, for example ``"Load video"``.
|
|
67
|
+
:param link_href: Direct URL of the original content, unescaped.
|
|
68
|
+
:param link_text: The direct link text.
|
|
69
|
+
:returns: The preview HTML.
|
|
70
|
+
"""
|
|
71
|
+
script_attr = f' data-mw-script="{html.escape(script_src)}"' if script_src is not None else ""
|
|
72
|
+
return (
|
|
73
|
+
f'<div class="mw-embed" data-mw-version="{PREVIEW_VERSION}" data-mw-embed="{embed}"'
|
|
74
|
+
f' data-mw-load="{load}"{script_attr}>\n'
|
|
75
|
+
f' <template class="mw-embed-content">{content}</template>\n'
|
|
76
|
+
f' <div class="mw-embed-preview">\n'
|
|
77
|
+
f' <p class="mw-embed-provider">{provider}</p>\n'
|
|
78
|
+
f' <button type="button" class="mw-embed-load">{action}</button>\n'
|
|
79
|
+
f' <a class="mw-embed-link" href="{html.escape(link_href)}">{link_text}</a>\n'
|
|
80
|
+
f" </div>\n"
|
|
81
|
+
f"</div>"
|
|
82
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# ABOUTME: Shared scanner for fenced code blocks in Markdown source, following CommonMark's fence rules.
|
|
2
|
+
# Used by the fence pre stage (directive extraction) and the highlight pre stage (skipping code) so both agree.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
|
|
10
|
+
# An opening fence: indentation, then three or more backticks or tildes, then the info string
|
|
11
|
+
_FENCE_OPEN_RE = re.compile(r"^(?P<indent>[ ]*)(?P<marker>`{3,}|~{3,})(?P<info>.*)$")
|
|
12
|
+
# A list item's first line: indentation, a bullet or ordinal marker, then content
|
|
13
|
+
_LIST_ITEM_RE = re.compile(r"^(?P<indent>[ ]*)(?:[-*+]|\d{1,9}[.)])[ ]+\S")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FencedBlock(BaseModel):
|
|
17
|
+
"""A closed fenced code block found in Markdown source.
|
|
18
|
+
|
|
19
|
+
:ivar opening: Line index of the opening fence.
|
|
20
|
+
:ivar closing: Line index of the closing fence.
|
|
21
|
+
:ivar indent: The opening fence's indentation, repeated on lines the pre stage inserts.
|
|
22
|
+
:ivar marker: The opening fence run, for example ``\\`\\`\\``` or ``~~~~``.
|
|
23
|
+
:ivar info: The info string after the opening fence, unstripped.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(frozen=True)
|
|
27
|
+
|
|
28
|
+
opening: int
|
|
29
|
+
closing: int
|
|
30
|
+
indent: str
|
|
31
|
+
marker: str
|
|
32
|
+
info: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_fenced_blocks(lines: list[str]) -> list[FencedBlock]:
|
|
36
|
+
"""Find the closed fenced code blocks in Markdown source lines.
|
|
37
|
+
|
|
38
|
+
Follows CommonMark: an opening fence is indented up to three spaces (or sits inside a list
|
|
39
|
+
item, under the item's content), a backtick fence's info string holds no backtick, and the
|
|
40
|
+
closing fence uses the same character, is at least as long, and carries nothing else. An
|
|
41
|
+
unclosed fence is not a code block, matching ``pymdownx.superfences``.
|
|
42
|
+
|
|
43
|
+
:param lines: Source lines.
|
|
44
|
+
:returns: The fenced blocks in source order; nested fences inside a block are content.
|
|
45
|
+
"""
|
|
46
|
+
blocks: list[FencedBlock] = []
|
|
47
|
+
line_index = 0
|
|
48
|
+
while line_index < len(lines):
|
|
49
|
+
open_match = _FENCE_OPEN_RE.match(lines[line_index])
|
|
50
|
+
if (
|
|
51
|
+
open_match is None
|
|
52
|
+
or (open_match["marker"][0] == "`" and "`" in open_match["info"])
|
|
53
|
+
or not _fence_can_open(lines, line_index, len(open_match["indent"]))
|
|
54
|
+
):
|
|
55
|
+
line_index += 1
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# The closing fence: same character, at least as long, nothing after it
|
|
59
|
+
marker = open_match["marker"]
|
|
60
|
+
closing = next(
|
|
61
|
+
(
|
|
62
|
+
scan_index
|
|
63
|
+
for scan_index in range(line_index + 1, len(lines))
|
|
64
|
+
if (stripped := lines[scan_index].strip()).startswith(marker)
|
|
65
|
+
and set(stripped) == {marker[0]}
|
|
66
|
+
and len(lines[scan_index]) - len(lines[scan_index].lstrip(" ")) <= len(open_match["indent"]) + 3
|
|
67
|
+
),
|
|
68
|
+
None,
|
|
69
|
+
)
|
|
70
|
+
if closing is None:
|
|
71
|
+
line_index += 1
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
blocks.append(
|
|
75
|
+
FencedBlock(
|
|
76
|
+
opening=line_index,
|
|
77
|
+
closing=closing,
|
|
78
|
+
indent=open_match["indent"],
|
|
79
|
+
marker=marker,
|
|
80
|
+
info=open_match["info"],
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
line_index = closing + 1
|
|
84
|
+
return blocks
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fence_can_open(lines: list[str], line_index: int, indent_width: int) -> bool:
|
|
88
|
+
"""Report whether a fence-shaped line at this indentation opens a fence or is indented code.
|
|
89
|
+
|
|
90
|
+
Up to three spaces always opens a fence. Four or more opens one only inside a list item: the
|
|
91
|
+
nearest earlier non-blank line indented less than the fence must be a list item, and the fence
|
|
92
|
+
must sit at or past that item's content.
|
|
93
|
+
|
|
94
|
+
:param lines: Source lines.
|
|
95
|
+
:param line_index: Index of the fence-shaped line.
|
|
96
|
+
:param indent_width: The line's indentation in spaces.
|
|
97
|
+
:returns: ``True`` if the line opens a fence.
|
|
98
|
+
"""
|
|
99
|
+
if indent_width <= 3:
|
|
100
|
+
return True
|
|
101
|
+
for earlier_line in reversed(lines[:line_index]):
|
|
102
|
+
earlier_indent = len(earlier_line) - len(earlier_line.lstrip(" "))
|
|
103
|
+
if not earlier_line.strip() or earlier_indent >= indent_width:
|
|
104
|
+
continue
|
|
105
|
+
item_match = _LIST_ITEM_RE.match(earlier_line)
|
|
106
|
+
return item_match is not None and indent_width >= len(item_match["indent"]) + 2
|
|
107
|
+
return False
|