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.
+
+
+
+
Network
+
Band
+
+
+
+
+
GSM 900
+
900 MHz
+
+
+
+
+
First
+
Second
+
+
+
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(