markwright 0.2.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.
Files changed (51) hide show
  1. markwright-0.3.0/.gitignore +25 -0
  2. markwright-0.3.0/CHANGELOG.md +91 -0
  3. {markwright-0.2.0 → markwright-0.3.0}/PKG-INFO +34 -29
  4. {markwright-0.2.0 → markwright-0.3.0}/README.md +24 -19
  5. markwright-0.2.0/pyproject.toml.orig → markwright-0.3.0/pyproject.toml +14 -9
  6. markwright-0.3.0/src/markwright/__init__.py +17 -0
  7. markwright-0.3.0/src/markwright/_diagnostics.py +20 -0
  8. markwright-0.3.0/src/markwright/_embed_load.py +82 -0
  9. markwright-0.3.0/src/markwright/_fences.py +107 -0
  10. markwright-0.3.0/src/markwright/_html.py +225 -0
  11. markwright-0.3.0/src/markwright/cli.py +243 -0
  12. markwright-0.3.0/src/markwright/codepen.py +270 -0
  13. markwright-0.3.0/src/markwright/config.py +285 -0
  14. markwright-0.3.0/src/markwright/fence.py +553 -0
  15. markwright-0.3.0/src/markwright/highlight.py +220 -0
  16. {markwright-0.2.0 → markwright-0.3.0}/src/markwright/image_compare.py +20 -28
  17. {markwright-0.2.0 → markwright-0.3.0}/src/markwright/instagram.py +105 -47
  18. markwright-0.3.0/src/markwright/registry.py +153 -0
  19. {markwright-0.2.0 → markwright-0.3.0}/src/markwright/slideshow.py +26 -33
  20. {markwright-0.2.0 → markwright-0.3.0}/src/markwright/twitter.py +102 -45
  21. {markwright-0.2.0 → markwright-0.3.0}/src/markwright/youtube.py +54 -17
  22. markwright-0.3.0/tests/integration/test_clean_install.py +117 -0
  23. markwright-0.3.0/tests/integration/test_hugo_pipeline.py +165 -0
  24. markwright-0.3.0/tests/test_cli.py +620 -0
  25. markwright-0.3.0/tests/test_codepen.py +171 -0
  26. markwright-0.3.0/tests/test_config.py +385 -0
  27. markwright-0.3.0/tests/test_docs_tokens.py +37 -0
  28. markwright-0.3.0/tests/test_fence.py +623 -0
  29. markwright-0.3.0/tests/test_highlight.py +272 -0
  30. markwright-0.3.0/tests/test_image_compare.py +152 -0
  31. markwright-0.3.0/tests/test_instagram.py +206 -0
  32. markwright-0.3.0/tests/test_load.py +205 -0
  33. markwright-0.3.0/tests/test_packaging.py +53 -0
  34. markwright-0.3.0/tests/test_registry.py +65 -0
  35. markwright-0.3.0/tests/test_roundtrip.py +304 -0
  36. markwright-0.3.0/tests/test_slideshow.py +177 -0
  37. markwright-0.3.0/tests/test_twitter.py +188 -0
  38. markwright-0.3.0/tests/test_youtube.py +140 -0
  39. markwright-0.2.0/pyproject.toml +0 -81
  40. markwright-0.2.0/src/markwright/__init__.py +0 -0
  41. markwright-0.2.0/src/markwright/_util.py +0 -31
  42. markwright-0.2.0/src/markwright/cli.py +0 -245
  43. markwright-0.2.0/src/markwright/codepen.py +0 -208
  44. markwright-0.2.0/src/markwright/config.py +0 -220
  45. markwright-0.2.0/src/markwright/fence.py +0 -500
  46. markwright-0.2.0/src/markwright/highlight.py +0 -191
  47. markwright-0.2.0/src/markwright/registry.py +0 -135
  48. {markwright-0.2.0 → markwright-0.3.0}/LICENSE +0 -0
  49. {markwright-0.2.0 → markwright-0.3.0}/LICENSE-Apache-2.0 +0 -0
  50. {markwright-0.2.0 → markwright-0.3.0}/NOTICE +0 -0
  51. {markwright-0.2.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="&lt;^&gt;"`) 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,27 +1,27 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: markwright
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python-Markdown extensions ported from DigitalOcean's do-markdownit
5
- Keywords: markdown,python-markdown,do-markdownit,mkdocs,hugo
6
- Author: Mason Egger
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
- Classifier: License :: OSI Approved :: MIT License
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
- Project-URL: Homepage, https://github.com/MasonEgger/markwright
23
- Project-URL: Repository, https://github.com/MasonEgger/markwright
24
- Project-URL: Documentation, https://masonegger.github.io/markwright/
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
@@ -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(extensions=[
102
- "pymdownx.superfences",
103
- "pymdownx.highlight",
104
- "markwright.highlight",
105
- "markwright.fence",
106
- "markwright.youtube",
107
- "markwright.codepen",
108
- "markwright.twitter",
109
- "markwright.instagram",
110
- "markwright.slideshow",
111
- "markwright.image_compare",
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)
@@ -178,17 +184,16 @@ See the [extension docs](https://masonegger.github.io/markwright/) for every fla
178
184
  This project uses [`uv`](https://docs.astral.sh/uv/) and [`just`](https://github.com/casey/just).
179
185
 
180
186
  ```bash
181
- just install # uv sync
182
- just test # unit tests, 100% line + branch coverage
183
- just test-integration # end-to-end Hugo pipeline test (needs hugo)
184
- just lint # ruff check + ruff format --check
185
- just typecheck # mypy --strict
186
- 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
187
192
  just docs-serve # serve the docs site at localhost:8000
188
193
  ```
189
194
 
190
195
  `just check` must pass before a change is complete.
191
- The tooling bar is strict: `mypy --strict`, a clean `ruff`, and full test coverage.
196
+ Coverage is reported on every run.
192
197
 
193
198
  ## License
194
199
 
@@ -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(extensions=[
76
- "pymdownx.superfences",
77
- "pymdownx.highlight",
78
- "markwright.highlight",
79
- "markwright.fence",
80
- "markwright.youtube",
81
- "markwright.codepen",
82
- "markwright.twitter",
83
- "markwright.instagram",
84
- "markwright.slideshow",
85
- "markwright.image_compare",
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)
@@ -152,17 +158,16 @@ See the [extension docs](https://masonegger.github.io/markwright/) for every fla
152
158
  This project uses [`uv`](https://docs.astral.sh/uv/) and [`just`](https://github.com/casey/just).
153
159
 
154
160
  ```bash
155
- just install # uv sync
156
- just test # unit tests, 100% line + branch coverage
157
- just test-integration # end-to-end Hugo pipeline test (needs hugo)
158
- just lint # ruff check + ruff format --check
159
- just typecheck # mypy --strict
160
- 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
161
166
  just docs-serve # serve the docs site at localhost:8000
162
167
  ```
163
168
 
164
169
  `just check` must pass before a change is complete.
165
- The tooling bar is strict: `mypy --strict`, a clean `ruff`, and full test coverage.
170
+ Coverage is reported on every run.
166
171
 
167
172
  ## License
168
173
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "markwright"
3
- version = "0.2.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
- "pymdown-extensions>=10.5",
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,<2",
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 = ["uv_build>=0.9.17,<0.13.0"]
48
- build-backend = "uv_build"
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