From 884300f3cf1077c4f0ff139cbfaeaa4ec999fad4 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 20:55:07 +0300 Subject: [PATCH] fix: ship the real DTD in the Pelican plugin --- pelican-mews/README.md | 7 +- .../pelican/plugins/mews/dtd/mews.dtd | 441 +++++++++++++++--- pelican-mews/tests/conftest.py | 10 +- .../tests/fixtures/structure/expected.xhtml | 35 ++ .../tests/fixtures/structure/input.md | 16 + pelican-mews/tests/test_mews.py | 23 +- tests/test_data.py | 13 + tests/test_site.py | 12 + 8 files changed, 483 insertions(+), 74 deletions(-) create mode 100644 pelican-mews/tests/fixtures/structure/expected.xhtml create mode 100644 pelican-mews/tests/fixtures/structure/input.md diff --git a/pelican-mews/README.md b/pelican-mews/README.md index 4a92070..5c11a88 100644 --- a/pelican-mews/README.md +++ b/pelican-mews/README.md @@ -10,9 +10,10 @@ well-formed UTF-8 XML with the XHTML namespace on ``. The plugin also copies the Mews stylesheet into the output tree and gives themes a `MEWS_CSS` global plus the `mews/head.html` partial. -The bundled `pelican/plugins/mews/dtd/mews.dtd` is a minimal placeholder. -Replace it with the real Mews Profile DTD when it is available; the plugin -picks up the change without any code edit. +The subset comes from `pelican/plugins/mews/dtd/mews.dtd`, which is a copy of +the Mews Profile 0.1 DTD. Only element and attribute names are read from it, so +the plugin lists none of them in code: change the DTD and the allowlist changes +with it. ## Install diff --git a/pelican-mews/pelican/plugins/mews/dtd/mews.dtd b/pelican-mews/pelican/plugins/mews/dtd/mews.dtd index 1eb98d3..e09c5da 100644 --- a/pelican-mews/pelican/plugins/mews/dtd/mews.dtd +++ b/pelican-mews/pelican/plugins/mews/dtd/mews.dtd @@ -1,87 +1,396 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + - - - + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pelican-mews/tests/conftest.py b/pelican-mews/tests/conftest.py index fe7d5ba..1658f1e 100644 --- a/pelican-mews/tests/conftest.py +++ b/pelican-mews/tests/conftest.py @@ -21,7 +21,15 @@ THEME = TESTS / "theme" BASE_SETTINGS = { "ARTICLE_PATHS": [], - "MARKDOWN": {"output_format": "xhtml"}, + # Tables and definition lists are both in the Mews subset, and both need + # an extension turned on before python-markdown will emit them. + "MARKDOWN": { + "output_format": "xhtml", + "extension_configs": { + "markdown.extensions.tables": {}, + "markdown.extensions.def_list": {}, + }, + }, "PAGE_PATHS": ["pages"], "PAGE_SAVE_AS": "{slug}.xhtml", "PAGE_URL": "{slug}.xhtml", diff --git a/pelican-mews/tests/fixtures/structure/expected.xhtml b/pelican-mews/tests/fixtures/structure/expected.xhtml new file mode 100644 index 0000000..eb09b65 --- /dev/null +++ b/pelican-mews/tests/fixtures/structure/expected.xhtml @@ -0,0 +1,35 @@ + + + + +Structure + + + + +

A table, an ordered list and a definition list: everything the placeholder +DTD used to throw away.

+ + + + + + + + + + + + + +
NetworkBand
GSM 900900 MHz
+
    +
  1. First
  2. +
  3. Second
  4. +
+
+
Term
+
Its meaning
+
+ + \ No newline at end of file diff --git a/pelican-mews/tests/fixtures/structure/input.md b/pelican-mews/tests/fixtures/structure/input.md new file mode 100644 index 0000000..ffb05b6 --- /dev/null +++ b/pelican-mews/tests/fixtures/structure/input.md @@ -0,0 +1,16 @@ +Title: Structure +Slug: page +Date: 2026-10-11 + +A table, an ordered list and a definition list: everything the placeholder +DTD used to throw away. + +| Network | Band | +| --- | --- | +| GSM 900 | 900 MHz | + +1. First +2. Second + +Term +: Its meaning diff --git a/pelican-mews/tests/test_mews.py b/pelican-mews/tests/test_mews.py index f14b12d..7267aa0 100644 --- a/pelican-mews/tests/test_mews.py +++ b/pelican-mews/tests/test_mews.py @@ -18,7 +18,7 @@ PACKAGE = "pelican.plugins.mews" STRICT_OVERRIDES = {"MEWS_STRICT": True} -@pytest.mark.parametrize("case", ["basic", "strip", "cssdir"]) +@pytest.mark.parametrize("case", ["basic", "strip", "cssdir", "structure"]) def test_written_page_matches_expected(tmp_path, case): """The written page matches the fixture, whatever was stripped.""" overrides = {"MEWS_CSS_DIR": "style"} if case == "cssdir" else {} @@ -28,7 +28,7 @@ def test_written_page_matches_expected(tmp_path, case): assert normalize(page) == normalize(expected) -@pytest.mark.parametrize("case", ["basic", "strip", "cssdir"]) +@pytest.mark.parametrize("case", ["basic", "strip", "cssdir", "structure"]) def test_css_is_copied(tmp_path, case): """The bundled stylesheet lands under the configured directory.""" overrides = {"MEWS_CSS_DIR": "style"} if case == "cssdir" else {} @@ -81,13 +81,28 @@ def test_parallel_output_folder(tmp_path): assert not (output / "css").exists() +# Section 4.1 of the specification keeps this many elements. The plugin lists +# none of them itself; the DTD is the only place they are written down. +SECTION_4_1_ELEMENTS = 51 + + def test_allowlist_is_read_from_the_dtd(): """The subset comes from the DTD, not from code.""" dtd = files(PACKAGE).joinpath("dtd", "mews.dtd").read_text(encoding="utf-8") allowlist = parse_allowlist(dtd) - assert allowlist["p"] == {"id", "title", "xml:lang"} - assert allowlist["html"] == {"dir", "id", "lang", "title", "xml:lang", "xmlns"} + assert len(allowlist) == SECTION_4_1_ELEMENTS + # The common attributes, including the two the profile adds to XHTML-MP. + assert allowlist["p"] == {"dir", "id", "lang", "title", "xml:lang"} + # html takes no id or title; section 4.1 gives it these four and no more. + assert allowlist["html"] == {"dir", "lang", "xml:lang", "xmlns"} assert "href" in allowlist["a"] + # class is permitted on body and nowhere else (section 5.3). + assert "class" in allowlist["body"] + assert not [ + name for name, attrs in allowlist.items() if name != "body" and "class" in attrs + ] + # The parts the placeholder DTD lacked, which the plugin was stripping. + assert {"table", "td", "ol", "dl", "form", "input", "address"} <= set(allowlist) @pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed") diff --git a/tests/test_data.py b/tests/test_data.py index 956dad8..2ea1c32 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -14,6 +14,12 @@ COPIES = { "mews-0.1.dtd": ROOT / "dtd" / "mews-0.1.dtd", } +# The Pelican plugin is a separate distributable package, so it carries its own +# copy of the DTD rather than importing one. It shipped a hand-written +# placeholder for a while, which silently stripped tables, ordered lists, forms +# and the body variant class out of every page it produced. +PLUGIN_DTD = ROOT / "pelican-mews" / "pelican" / "plugins" / "mews" / "dtd" / "mews.dtd" + def test_packaged_copies_match_the_originals(): for name, original in COPIES.items(): @@ -28,3 +34,10 @@ def test_the_spec_quotes_the_same_stylesheet(): spec = (ROOT / "doc" / "SPEC.md").read_text() quoted = spec.split("```css\n", 1)[1].split("```", 1)[0] assert quoted.strip() == (ROOT / "mews-0.1.css").read_text().strip() + + +def test_the_pelican_plugin_bundles_the_real_dtd(): + canonical = (ROOT / "dtd" / "mews-0.1.dtd").read_bytes() + assert PLUGIN_DTD.read_bytes() == canonical, ( + f"{PLUGIN_DTD} differs from dtd/mews-0.1.dtd. Copy the original over it." + ) diff --git a/tests/test_site.py b/tests/test_site.py index 39445a7..560b4a7 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -24,6 +24,18 @@ def test_the_projects_own_pages_have_no_warnings_either(page): assert not report.warnings, "\n".join(str(f) for f in report.warnings) +PLUGIN_PAGES = sorted( + (ROOT / "pelican-mews" / "tests" / "fixtures").glob("*/expected.xhtml") +) + + +@pytest.mark.parametrize("page", PLUGIN_PAGES, ids=lambda p: p.parent.name) +def test_pages_the_pelican_plugin_produces_conform(page): + """What the plugin writes has to pass the validator, or it is no use.""" + report = validate_file(str(page)) + assert report.conforms, "\n".join(str(f) for f in report.failures) + + def test_the_dtd_still_matches_the_spec(): """dtd/check_dtd.py is hand-maintained; keep it running in CI.""" result = subprocess.run(