fix: ship the real DTD in the Pelican plugin

This commit is contained in:
randogoth 2026-10-11 20:55:07 +03:00
parent f54d350216
commit 884300f3cf
8 changed files with 483 additions and 74 deletions

View file

@ -10,9 +10,10 @@ well-formed UTF-8 XML with the XHTML namespace on `<html>`. 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

View file

@ -1,87 +1,396 @@
<!-- Placeholder for the Mews subset definition. The real allowlist comes
from the Mews Profile DTD; replace this file when it is available.
Only element and attribute names are read; content models are
illustrative. -->
<!-- Mews Profile 0.1 DTD
Published at mews.page/dtd/mews-0.1.dtd. Browsers and clients must not
fetch this file.
<!ENTITY % common
"id ID #IMPLIED
title CDATA #IMPLIED
xml:lang NMTOKEN #IMPLIED">
XHTML Mobile Profile 1.2 restricted as in the Mews Profile
Specification 0.1 section 4, extended with lang and dir (section 3).
Maintained by hand and checked against the XHTML Mobile 1.2 driver,
xhtml-mobile12.dtd (OMA-SUP-DTD_xhtml_mobile12-V1_2-20080331-A), by
check_dtd.py. It cannot be generated by stripping the driver: the
driver declares no elements itself, attribute lists only ever add,
and the rules below do not exist in XHTML-MP to be kept.
<!ELEMENT html (head, body) >
<!ATTLIST html
xmlns CDATA #FIXED "http://www.w3.org/1999/xhtml"
lang NMTOKEN #IMPLIED
dir (ltr | rtl) #IMPLIED
%common;
This file is standalone: it declares every element and attribute
itself, so validation never depends on the Open Mobile Alliance's or
W3C's servers. Pages keep the XHTML-MP doctype (section 3.1); only
validators use this file (section 9.1).
Encoded here: the permitted elements and attributes (4.1), the head
rules (3.3), the body variant classes (5.3) and the ban on nested
tables (4.2). 51 elements are kept from XHTML Mobile 1.2. Dropped:
acronym, base, big, button, div, legend, noscript, object, param,
script, small, span, style, sub, sup, tt. Event attributes and the
style attribute are omitted entirely.
-->
<!-- Datatypes (as in XHTML Modularization) -->
<!ENTITY % Text.datatype "CDATA" >
<!ENTITY % URI.datatype "CDATA" >
<!ENTITY % Number.datatype "CDATA" >
<!ENTITY % LanguageCode.datatype "NMTOKEN" >
<!ENTITY % Character.datatype "CDATA" >
<!-- Common attributes. id, title, xml:lang, lang and dir are permitted
on every element in the body (SPEC.md 4.1); lang and dir are the Mews
extensions over XHTML-MP (SPEC.md 3). -->
<!ENTITY % id.attrib "id ID #IMPLIED" >
<!ENTITY % title.attrib "title CDATA #IMPLIED" >
<!ENTITY % i18n.attrib
"xml:lang %LanguageCode.datatype; #IMPLIED
lang %LanguageCode.datatype; #IMPLIED
dir (ltr | rtl) #IMPLIED" >
<!ENTITY % Common.attrib "%id.attrib; %title.attrib; %i18n.attrib;" >
<!-- Inline classes -->
<!ENTITY % InlStruct.class "br" >
<!ENTITY % InlPhras.class "| em | strong | code | kbd | samp | var | q | cite | abbr | dfn" >
<!ENTITY % InlPres.class "| b | i" >
<!ENTITY % Anchor.class "| a" >
<!ENTITY % InlSpecial.class "| img" >
<!ENTITY % InlFormNoLabel.class "| input | select | textarea" >
<!ENTITY % InlForm.class "%InlFormNoLabel.class; | label" >
<!ENTITY % Inline.extra "" >
<!ENTITY % Inline.class "%InlStruct.class;
%InlPhras.class;
%InlPres.class;
%Anchor.class;
%InlSpecial.class;
%InlForm.class;
%Inline.extra;" >
<!ENTITY % InlNoAnchor.class "%InlStruct.class;
%InlPhras.class;
%InlPres.class;
%InlSpecial.class;
%InlForm.class;
%Inline.extra;" >
<!ENTITY % InlNoLabel.class "%InlStruct.class;
%InlPhras.class;
%InlPres.class;
%Anchor.class;
%InlSpecial.class;
%InlFormNoLabel.class;
%Inline.extra;" >
<!-- Block classes -->
<!ENTITY % Heading.class "h1 | h2 | h3 | h4 | h5 | h6" >
<!ENTITY % List.class "ul | ol | dl" >
<!ENTITY % BlkStruct.class "p" >
<!ENTITY % BlkPhras.class "| pre | blockquote | address" >
<!ENTITY % BlkPres.class "| hr" >
<!ENTITY % Table.class "| table" >
<!ENTITY % Form.class "| form" >
<!ENTITY % Fieldset.class "| fieldset" >
<!ENTITY % Block.extra "" >
<!ENTITY % BlkNoForm.class "%Heading.class;
| %List.class;
| %BlkStruct.class;
%BlkPhras.class;
%BlkPres.class;
%Fieldset.class;
%Block.extra;" >
<!ENTITY % BlkNoTable.class "%BlkNoForm.class;
%Form.class;
%Block.extra;" >
<!ENTITY % Blk.class "%BlkNoTable.class;
%Table.class;
%Block.extra;" >
<!ENTITY % FlowNoTable.class "%Inline.class;
| %BlkNoTable.class;" >
<!-- pre content model, taken verbatim from the driver. It excludes
%InlPres.class; and %InlSpecial.class;, so b, i and img are not
permitted in pre, as in XHTML Mobile 1.2. -->
<!ENTITY % pre.content
"( #PCDATA
| %InlStruct.class;
%InlPhras.class;
%Anchor.class;
%Inline.extra; )*"
>
<!ELEMENT head (title, meta, link)* >
<!ELEMENT title (#PCDATA) >
<!ATTLIST title %common; >
<!-- Document structure (SPEC.md 4.1) -->
<!--
Only body uses %Blk.class;, so a table can appear directly in the
body and nowhere else, and tables cannot nest (SPEC.md 4.2). body may
carry one class from the variant list (SPEC.md 5.3).
-->
<!ELEMENT html ( head, body ) >
<!ATTLIST html
xmlns CDATA #FIXED "http://www.w3.org/1999/xhtml"
xml:lang %LanguageCode.datatype; #IMPLIED
lang %LanguageCode.datatype; #IMPLIED
dir (ltr | rtl) #IMPLIED
>
<!ELEMENT head ( title, meta, ( meta | link )* ) >
<!ELEMENT title ( #PCDATA )* >
<!ELEMENT body ( %Blk.class; )+ >
<!ATTLIST body
%Common.attrib;
class (mews-warm | mews-cool | mews-green | mews-mono) #IMPLIED
>
<!-- Head elements (SPEC.md 3.3) -->
<!--
The head may contain only title, meta and link. The title comes
first and at least one meta is required; the conformance marker is a
meta (SPEC.md 3.2), but a DTD cannot check its name and content
together, so validators confirm the marker as a rule check (SPEC.md
9.1). link rel is limited to the uses SPEC.md 3.3 names: the
stylesheet, the icon and feed links.
-->
<!ELEMENT meta EMPTY >
<!ATTLIST meta
name NMTOKEN #IMPLIED
content CDATA #REQUIRED
name NMTOKEN #IMPLIED
content CDATA #REQUIRED
>
<!ELEMENT link EMPTY >
<!ATTLIST link
rel CDATA #IMPLIED
href CDATA #IMPLIED
type CDATA #IMPLIED
title CDATA #IMPLIED
%common;
rel (alternate | icon | stylesheet) #IMPLIED
href %URI.datatype; #IMPLIED
type CDATA #IMPLIED
title CDATA #IMPLIED
>
<!ELEMENT body (#PCDATA | h1 | h2 | h3 | h4 | h5 | h6 | p | ul | blockquote | pre | hr | a | img)* >
<!ATTLIST body %common; >
<!-- Headings (SPEC.md 4.1) -->
<!ELEMENT h1 ( #PCDATA | %Inline.class; )* >
<!ATTLIST h1
%Common.attrib;
>
<!ELEMENT h2 ( #PCDATA | %Inline.class; )* >
<!ATTLIST h2
%Common.attrib;
>
<!ELEMENT h3 ( #PCDATA | %Inline.class; )* >
<!ATTLIST h3
%Common.attrib;
>
<!ELEMENT h4 ( #PCDATA | %Inline.class; )* >
<!ATTLIST h4
%Common.attrib;
>
<!ELEMENT h5 ( #PCDATA | %Inline.class; )* >
<!ATTLIST h5
%Common.attrib;
>
<!ELEMENT h6 ( #PCDATA | %Inline.class; )* >
<!ATTLIST h6
%Common.attrib;
>
<!ELEMENT h1 (#PCDATA) >
<!ATTLIST h1 %common; >
<!ELEMENT h2 (#PCDATA) >
<!ATTLIST h2 %common; >
<!ELEMENT h3 (#PCDATA) >
<!ATTLIST h3 %common; >
<!ELEMENT h4 (#PCDATA) >
<!ATTLIST h4 %common; >
<!ELEMENT h5 (#PCDATA) >
<!ATTLIST h5 %common; >
<!ELEMENT h6 (#PCDATA) >
<!ATTLIST h6 %common; >
<!-- Text blocks (SPEC.md 4.1) -->
<!ELEMENT p ( #PCDATA | %Inline.class; )* >
<!ATTLIST p
%Common.attrib;
>
<!ELEMENT blockquote ( %BlkNoTable.class; )* >
<!ATTLIST blockquote
%Common.attrib;
cite %URI.datatype; #IMPLIED
>
<!ELEMENT pre %pre.content; >
<!ATTLIST pre
%Common.attrib;
>
<!ELEMENT address ( #PCDATA | %Inline.class; )* >
<!ATTLIST address
%Common.attrib;
>
<!ELEMENT hr EMPTY >
<!ATTLIST hr
%Common.attrib;
>
<!ELEMENT p (#PCDATA | em | strong | code | a | img | br)* >
<!ATTLIST p %common; >
<!ELEMENT em (#PCDATA) >
<!ATTLIST em %common; >
<!ELEMENT strong (#PCDATA) >
<!ATTLIST strong %common; >
<!ELEMENT code (#PCDATA) >
<!ATTLIST code %common; >
<!ELEMENT a (#PCDATA) >
<!-- Inline text (SPEC.md 4.1) -->
<!ELEMENT em ( #PCDATA | %Inline.class; )* >
<!ATTLIST em
%Common.attrib;
>
<!ELEMENT strong ( #PCDATA | %Inline.class; )* >
<!ATTLIST strong
%Common.attrib;
>
<!ELEMENT code ( #PCDATA | %Inline.class; )* >
<!ATTLIST code
%Common.attrib;
>
<!ELEMENT kbd ( #PCDATA | %Inline.class; )* >
<!ATTLIST kbd
%Common.attrib;
>
<!ELEMENT samp ( #PCDATA | %Inline.class; )* >
<!ATTLIST samp
%Common.attrib;
>
<!ELEMENT var ( #PCDATA | %Inline.class; )* >
<!ATTLIST var
%Common.attrib;
>
<!ELEMENT cite ( #PCDATA | %Inline.class; )* >
<!ATTLIST cite
%Common.attrib;
>
<!ELEMENT abbr ( #PCDATA | %Inline.class; )* >
<!ATTLIST abbr
%Common.attrib;
>
<!ELEMENT dfn ( #PCDATA | %Inline.class; )* >
<!ATTLIST dfn
%Common.attrib;
>
<!ELEMENT b ( #PCDATA | %Inline.class; )* >
<!ATTLIST b
%Common.attrib;
>
<!ELEMENT i ( #PCDATA | %Inline.class; )* >
<!ATTLIST i
%Common.attrib;
>
<!ELEMENT q ( #PCDATA | %Inline.class; )* >
<!ATTLIST q
%Common.attrib;
cite %URI.datatype; #IMPLIED
>
<!ELEMENT br EMPTY >
<!ATTLIST br
%Common.attrib;
>
<!ELEMENT a ( #PCDATA | %InlNoAnchor.class; )* >
<!ATTLIST a
href CDATA #IMPLIED
title CDATA #IMPLIED
rel CDATA #IMPLIED
%common;
%Common.attrib;
href %URI.datatype; #IMPLIED
hreflang %LanguageCode.datatype; #IMPLIED
rel CDATA #IMPLIED
type CDATA #IMPLIED
accesskey %Character.datatype; #IMPLIED
>
<!ELEMENT img EMPTY >
<!ATTLIST img
src CDATA #IMPLIED
alt CDATA #IMPLIED
%common;
%Common.attrib;
src %URI.datatype; #REQUIRED
alt %Text.datatype; #REQUIRED
>
<!ELEMENT br EMPTY >
<!ELEMENT ul (li)+ >
<!ATTLIST ul %common; >
<!ELEMENT li (#PCDATA | p | ul)* >
<!ATTLIST li %common; >
<!-- Lists (SPEC.md 4.1) -->
<!ELEMENT ul ( li )+ >
<!ATTLIST ul
%Common.attrib;
>
<!ELEMENT ol ( li )+ >
<!ATTLIST ol
%Common.attrib;
start %Number.datatype; #IMPLIED
>
<!ELEMENT dl ( dt | dd )+ >
<!ATTLIST dl
%Common.attrib;
>
<!ELEMENT li ( #PCDATA | %FlowNoTable.class; )* >
<!ATTLIST li
%Common.attrib;
value %Number.datatype; #IMPLIED
>
<!ELEMENT dt ( #PCDATA | %Inline.class; )* >
<!ATTLIST dt
%Common.attrib;
>
<!ELEMENT dd ( #PCDATA | %FlowNoTable.class; )* >
<!ATTLIST dd
%Common.attrib;
>
<!ELEMENT blockquote (p)* >
<!ATTLIST blockquote %common; >
<!ELEMENT pre (#PCDATA) >
<!ATTLIST pre %common; >
<!ELEMENT hr EMPTY >
<!-- Tables (SPEC.md 4.2) -->
<!--
As in the XHTML Mobile basic tables module, cells take
%FlowNoTable.class; and never a table, so tables cannot nest
(SPEC.md 4.2).
-->
<!ELEMENT table ( caption?, tr+ ) >
<!ATTLIST table
%Common.attrib;
summary %Text.datatype; #IMPLIED
>
<!ELEMENT caption ( #PCDATA | %Inline.class; )* >
<!ATTLIST caption
%Common.attrib;
>
<!ELEMENT tr ( th | td )+ >
<!ATTLIST tr
%Common.attrib;
>
<!ELEMENT th ( #PCDATA | %FlowNoTable.class; )* >
<!ATTLIST th
%Common.attrib;
rowspan %Number.datatype; "1"
colspan %Number.datatype; "1"
scope (row | col) #IMPLIED
>
<!ELEMENT td ( #PCDATA | %FlowNoTable.class; )* >
<!ATTLIST td
%Common.attrib;
rowspan %Number.datatype; "1"
colspan %Number.datatype; "1"
scope (row | col) #IMPLIED
>
<!-- Forms (SPEC.md 4.1, 4.2) -->
<!--
As in the XHTML MP forms module, form takes %BlkNoForm.class;, so
forms cannot nest. fieldset also takes %BlkNoForm.class;, so a
fieldset cannot reintroduce a form.
-->
<!ELEMENT form ( %BlkNoForm.class; )+ >
<!ATTLIST form
%Common.attrib;
action %URI.datatype; #REQUIRED
method (get | post) "get"
enctype CDATA "application/x-www-form-urlencoded"
>
<!ELEMENT fieldset ( #PCDATA | %Inline.class; | %BlkNoForm.class; )* >
<!ATTLIST fieldset
%Common.attrib;
>
<!ELEMENT label ( #PCDATA | %InlNoLabel.class; )* >
<!ATTLIST label
%Common.attrib;
for IDREF #IMPLIED
accesskey %Character.datatype; #IMPLIED
>
<!ELEMENT input EMPTY >
<!ATTLIST input
%Common.attrib;
type (text | password | checkbox | radio | submit | reset | hidden) "text"
name CDATA #IMPLIED
value CDATA #IMPLIED
checked (checked) #IMPLIED
size %Number.datatype; #IMPLIED
maxlength %Number.datatype; #IMPLIED
accesskey %Character.datatype; #IMPLIED
inputmode CDATA #IMPLIED
>
<!ELEMENT select ( optgroup | option )+ >
<!ATTLIST select
%Common.attrib;
name CDATA #IMPLIED
multiple (multiple) #IMPLIED
size %Number.datatype; #IMPLIED
>
<!ELEMENT optgroup ( option )+ >
<!ATTLIST optgroup
%Common.attrib;
label %Text.datatype; #REQUIRED
>
<!ELEMENT option ( #PCDATA )* >
<!ATTLIST option
%Common.attrib;
value CDATA #IMPLIED
selected (selected) #IMPLIED
>
<!ELEMENT textarea ( #PCDATA )* >
<!ATTLIST textarea
%Common.attrib;
name CDATA #IMPLIED
rows %Number.datatype; #REQUIRED
cols %Number.datatype; #REQUIRED
accesskey %Character.datatype; #IMPLIED
inputmode CDATA #IMPLIED
>

View file

@ -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",

View file

@ -0,0 +1,35 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN" "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Structure</title>
<meta name="mews-profile" content="0.1"/>
<meta name="viewport" content="width=device-width"/>
<link rel="stylesheet" type="text/css" href="css/mews-0.1.css"/></head>
<body>
<p>A table, an ordered list and a definition list: everything the placeholder
DTD used to throw away.</p>
<table>
<tr>
<th>Network</th>
<th>Band</th>
</tr>
<tr>
<td>GSM 900</td>
<td>900 MHz</td>
</tr>
</table>
<ol>
<li>First</li>
<li>Second</li>
</ol>
<dl>
<dt>Term</dt>
<dd>Its meaning</dd>
</dl>
</body>
</html>

View file

@ -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

View file

@ -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")