From 579fd2155fb6cdc0b1317c46d9720e08b4ae3e40 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Thu, 15 Oct 2026 14:34:30 +0200 Subject: [PATCH 1/3] html, eml: a title that says Page, tfg-forms and tfg-hidden, a newsletter that says its language A breaking change by the owner's decision of 2026-10-15 (docs/HTML-6-2026-10-15.md, O277, O282). A page no label fills - the smallest, and every one under --clean - carried , which the Nu HTML Checker reports as an error. It carries Page now, a word that says nothing about this tool. The smallest HTML page goes from 118 B to 122 B. The catalogue of every element names its forms section tfg-forms and its hidden field tfg-hidden, the way the links list is tfg-links. DOMPurify 3.4.17 took out id="forms" and name="hidden", both of which shadow a property of the document, so two links led nowhere and the field left a submitted form without its name. Measured after: it takes out nothing. A newsletter's page opens with , written in the frame so quoted-printable gives its = as =3D. A message laid out simple keeps its bytes. The structural checker now refuses a document whose title has no text and a newsletter page without its language, each with a canary. Two golden cases pin the placeholder pages, which nothing held. Measured with Nu before and after: the title errors and the language notice go, nothing is added. The eml settings charset, message_depth and filename_style say what Thunderbird 157 and MimeKit with a code page provider do with them (docs/EML-4-2026-10-15.md), in English and Polish. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 30 ++++++ internal/format/eml/message.go | 2 +- internal/format/eml/newsletter.go | 16 +++- internal/format/eml/settings.go | 7 +- internal/format/htmlfile/catalogue.go | 15 ++- internal/format/htmlfile/html.go | 2 +- internal/format/htmlfile/markup.go | 7 +- internal/format/htmlfile/page.go | 9 +- internal/guard/emlnewsletter_test.go | 4 + internal/guard/generatorbytes_test.go | 7 ++ internal/guard/htmlfragment_test.go | 16 +++- internal/guard/htmlsettings_test.go | 3 +- internal/guard/htmltitle_test.go | 94 +++++++++++++++++++ internal/guard/presetbytes_test.go | 6 +- internal/guard/testdata/generator-golden.json | 28 +++++- internal/gui/text/locale/registry/en.json | 12 +-- internal/gui/text/locale/registry/pl.json | 6 +- internal/oracle/strict.py | 9 ++ web/public/ar/formats/index.html | 2 +- .../ar/presets/empty-and-minimal/index.html | 2 +- web/public/cs/formaty/index.html | 2 +- .../cs/predvolby/empty-and-minimal/index.html | 2 +- web/public/de/formate/index.html | 2 +- .../de/presets/empty-and-minimal/index.html | 2 +- web/public/es/formatos/index.html | 2 +- .../es/presets/empty-and-minimal/index.html | 2 +- web/public/formats/index.html | 2 +- web/public/fr/formats/index.html | 2 +- .../prereglages/empty-and-minimal/index.html | 2 +- web/public/hi/formats/index.html | 2 +- .../hi/presets/empty-and-minimal/index.html | 2 +- web/public/id/format/index.html | 2 +- .../id/preset/empty-and-minimal/index.html | 2 +- web/public/it/formati/index.html | 2 +- .../it/preset/empty-and-minimal/index.html | 2 +- web/public/ja/formats/index.html | 2 +- .../ja/presets/empty-and-minimal/index.html | 2 +- web/public/ko/formats/index.html | 2 +- .../ko/presets/empty-and-minimal/index.html | 2 +- web/public/nl/formaten/index.html | 2 +- .../nl/presets/empty-and-minimal/index.html | 2 +- web/public/pl/formaty/index.html | 2 +- .../pl/presety/empty-and-minimal/index.html | 2 +- .../presets/empty-and-minimal/index.html | 2 +- web/public/pt-br/formatos/index.html | 2 +- .../presets/empty-and-minimal/index.html | 2 +- web/public/ro/formate/index.html | 2 +- .../ro/presetari/empty-and-minimal/index.html | 2 +- web/public/ru/formats/index.html | 2 +- .../ru/presets/empty-and-minimal/index.html | 2 +- web/public/th/formats/index.html | 2 +- .../th/presets/empty-and-minimal/index.html | 2 +- web/public/tr/bicimler/index.html | 2 +- .../empty-and-minimal/index.html | 2 +- web/public/uk/formats/index.html | 2 +- .../uk/presets/empty-and-minimal/index.html | 2 +- web/public/vi/dinh-dang/index.html | 2 +- .../vi/preset/empty-and-minimal/index.html | 2 +- web/public/zh-hans/formats/index.html | 2 +- .../presets/empty-and-minimal/index.html | 2 +- web/public/zh-hant/formats/index.html | 2 +- .../presets/empty-and-minimal/index.html | 2 +- 62 files changed, 282 insertions(+), 79 deletions(-) create mode 100644 internal/guard/htmltitle_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a80a6d..9723137b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,29 @@ because it turns other people's test suites red. ## [Unreleased] +### Breaking + +- **An HTML page with no label has a title that says Page.** The smallest + page and every page written with `--clean` carried an empty title, which + the HTML standard does not allow and the Nu HTML Checker reports as an + error. They now carry `Page`, a word that says nothing + about this tool, since `--clean` is there to keep it out of the file. + Pages with a label are unchanged. The smallest HTML page grows from 118 B + to 122 B, so 118 B to 121 B are now refused, and `tfg preset eject + empty-and-minimal` prints 122 where it printed 118 - a manifest from a run + of that preset records a different `recipe_hash`. +- **The catalogue of every element renames its forms section and its hidden + field.** With `elements=every` the forms section is `id="tfg-forms"` and + the hidden field is `name="tfg-hidden"`, the way the list of links has + always been `tfg-links`. DOMPurify removes an id or a name that shadows a + property of the document, so after sanitising, the two links to the forms + section led nowhere and the hidden field left a submitted form without its + name. The smallest catalogue grows from 6924 B to 6944 B. +- **A newsletter's page says its language.** With `layout=newsletter` the + HTML part opens with ``, as the pages of the `html` format + do. The smallest newsletter grows by 10 B, and by 12 B in quoted-printable + and base64. A message laid out `simple` is unchanged. + ### Added - **Films: the `webm` format.** A WebM video with an AV1 picture and no @@ -312,6 +335,13 @@ because it turns other people's test suites red. ### Changed +- **The `eml` settings say what Thunderbird and MimeKit do with them.** + Measured with Thunderbird 157.0.1 and MimeKit 4.18.1. `charset` says that + MimeKit reads Latin-2 and Windows-1250 right once a code page provider is + registered. `message_depth` says that Thunderbird shows 32 levels in full, + and past that shows only the headers of the 32nd message inside, without a + word. `filename_style` says that Thunderbird calls an attachment with no + name Part 1.2, Part 1.3 and so on. - **`empty-and-minimal` now includes the films, GeoJSON and mail.** The preset builds the smallest file of every format this build has, so with `formats` left at `all` it gains a `webm`, an `mp4`, a `geojson` and an `eml` target, and the recipe diff --git a/internal/format/eml/message.go b/internal/format/eml/message.go index dfc2e32c..d1f4acd3 100644 --- a/internal/format/eml/message.go +++ b/internal/format/eml/message.go @@ -189,7 +189,7 @@ func (m *message) plain(o *out) { // the pictures before them, in its frame. func (m *message) html(o *out) { eol := o.eol - o.put(m.frame.htmlBefore, "") + o.put(m.frame.htmlBefore, m.frame.htmlTag) if m.parity { o.put(" ") } diff --git a/internal/format/eml/newsletter.go b/internal/format/eml/newsletter.go index f356edc1..f6ecd019 100644 --- a/internal/format/eml/newsletter.go +++ b/internal/format/eml/newsletter.go @@ -44,15 +44,15 @@ type frame struct { headers string // plainOpen and plainClose stand before and after the plain text. plainOpen, plainClose string - // htmlBefore stands before , htmlOpen after it and htmlClose after - // the paragraph of words. - htmlBefore, htmlOpen, htmlClose string + // htmlBefore stands before the html element's start tag, htmlTag is that + // tag, htmlOpen stands after it and htmlClose after the paragraph of words. + htmlBefore, htmlTag, htmlOpen, htmlClose string } // simpleFrame is the frame of a message laid out simple: the body element and // nothing else, the bytes this format has always written. func simpleFrame(s settings) frame { - return frame{htmlOpen: "" + s.textEOL(), htmlClose: ""} + return frame{htmlTag: "", htmlOpen: "" + s.textEOL(), htmlClose: ""} } // newsletterFrame is the frame of a newsletter, unsubscribe being the https @@ -67,7 +67,13 @@ func newsletterFrame(s settings, unsubscribe string) frame { "You receive this because you subscribed to "+newsletterName+".") + frameLine(s, "Unsubscribe ("+unsubscribe+")"), htmlBefore: frameLines(s, ""), - htmlOpen: frameLines(s, newsletterHead...), + // The page says its language, as the pages of the html format do. The + // Nu HTML Checker asks for it, and a screen reader reads the page in + // the voice it names (until 2026-10-15 the newsletter had none). In the + // frame rather than in the writer, so quoted-printable writes its = as + // =3D like every other attribute. + htmlTag: frameLine(s, ``), + htmlOpen: frameLines(s, newsletterHead...), htmlClose: frameLines(s, newsletterFoot(unsubscribe)...) + frameLine(s, ""), } diff --git a/internal/format/eml/settings.go b/internal/format/eml/settings.go index 5c07d09a..d2d9780b 100644 --- a/internal/format/eml/settings.go +++ b/internal/format/eml/settings.go @@ -282,7 +282,7 @@ func charsetProperty() format.Property { Default: CharsetUTF8, Detail: "The character set of the text when text_encoding is not 7bit. iso-8859-2 and windows-1250 hold Polish and no Japanese, " + "and they write ą, ś and ź as different bytes, so a program that takes one for the other shows the wrong letters. " + - "MimeKit with no code page provider registered reads both as Latin-1, and Go's standard library converts no character set at all. " + + "MimeKit reads both as Latin-1 until a code page provider is registered, and right once one is. Go's standard library converts no character set at all. " + "With 7bit the text is plain ASCII and says us-ascii, whatever this is set to.", } } @@ -341,7 +341,7 @@ func filenameStyle() format.Property { Detail: "How the names of attached files and pictures are written. rfc2231 is the standard way. " + "rfc2047 is the encoded form RFC 2047 forbids inside a name, and many readers decode it anyway. " + "both writes the two, and content-type puts the name only where old mail programs looked for it. " + - "none writes no name at all, and a program that needs one has to make one up. " + + "none writes no name at all, and a program that needs one has to make one up - Thunderbird 157 calls them Part 1.2, Part 1.3 and so on. " + "Go's standard library reads no name from content-type and the encoded text itself from rfc2047. " + "Unless headers is encoded a name is written as it is and only where it goes changes, " + "so rfc2047 and both write the same bytes.", @@ -357,7 +357,8 @@ func depthProperty() format.Property { Default: "1", Detail: "How many messages deep the message goes. 1 is a message alone. 2 carries a forwarded message as an attachment, " + "and each step adds one more inside the last, the smallest message each can be. " + - "Python 3.14's email package runs out of recursion at around 490, and MimeKit stops taking them apart past 513 without a word.", + "Python 3.14's email package runs out of recursion at around 490, and MimeKit stops taking them apart past 513 without a word. " + + "Thunderbird 157 shows up to 32 in full. Past that the 32nd message inside shows only its headers and nothing under it appears, without a word.", Group: attachmentsGroup, } } diff --git a/internal/format/htmlfile/catalogue.go b/internal/format/htmlfile/catalogue.go index acc37e0e..cc11b854 100644 --- a/internal/format/htmlfile/catalogue.go +++ b/internal/format/htmlfile/catalogue.go @@ -42,15 +42,22 @@ const ( `` + "\n" + "\n" + // catalogueBody is the elements themselves. The forms article is + // tfg-forms rather than forms for the reason the links list is tfg-links: + // an id that shadows a document property is taken out by DOMPurify 3.4.17, + // and the two links to it then lead nowhere (O282, until 2026-10-15). The + // hidden field is named tfg-hidden for the same reason - a name hidden + // shadows document.hidden, DOMPurify took it out, and the field left a + // submitted form without one (measured 2026-10-15). catalogueBody = `

Every element

Every element of the HTML standard, each one at least once.

- +

Text

-

Plain link, HTML, bold, name, reversed, a title, code(), forty two, removed, term, stressed, alternate, added, Ctrl, marked, quoted, no longer accurate, output, fine print, span, important, H2O, x2, , annotated, n, longword, a line
break.

+

Plain link, HTML, bold, name, reversed, a title, code(), forty two, removed, term, stressed, alternate, added, Ctrl, marked, quoted, no longer accurate, output, fine print, span, important, H2O, x2, , annotated, n, longword, a line
break.

base(annotation) and x=2 and slot fallback and a custom element.

Third level

Fourth level

@@ -78,12 +85,12 @@ const ( footer row
-
+

Forms

Every type of input - + diff --git a/internal/format/htmlfile/html.go b/internal/format/htmlfile/html.go index 6ffd7aa9..b5b594e1 100644 --- a/internal/format/htmlfile/html.go +++ b/internal/format/htmlfile/html.go @@ -195,7 +195,7 @@ func labelledHead(s settings, r format.Request, min int64) (head, heading string // does - one for the tab and one for the reader. A fragment has no head // to put a title in, so it carries only the heading, and the label is // still visible because a heading is. - head = strings.Replace(plain, emptyTitle, ""+label+"", 1) + head = strings.Replace(plain, placeholderTitle, ""+label+"", 1) extra = int64(len(head) - len(plain)) } if extra+int64(len(heading))+min <= r.Bytes { diff --git a/internal/format/htmlfile/markup.go b/internal/format/htmlfile/markup.go index edbfe166..538b9e53 100644 --- a/internal/format/htmlfile/markup.go +++ b/internal/format/htmlfile/markup.go @@ -78,14 +78,15 @@ const ( // plain is how a page has been spelled since 2026-08-01, the golden hashes // holding it there. The skeleton is in two halves so the catalogue and the -// style sheet can go into the head between them, and the title is empty until a -// label fills it - the empty pair is what the smallest document carries. +// style sheet can go into the head between them, and the title says Page until +// a label fills it - what the smallest document and every --clean one carry +// (an empty title until 2026-10-15, O277). var plain = markup{ headOpen: `` + "\n" + `` + "\n" + `` + "\n" + `` + "\n" + - emptyTitle + "\n", + placeholderTitle + "\n", headClose: `` + "\n" + `` + "\n", bodyClose: "\n\n", diff --git a/internal/format/htmlfile/page.go b/internal/format/htmlfile/page.go index e80dc9a2..d69abdcf 100644 --- a/internal/format/htmlfile/page.go +++ b/internal/format/htmlfile/page.go @@ -9,8 +9,13 @@ package htmlfile // The markup itself - the skeleton, the label, the levels and the blocks - is // spelled in markup.go, in every layout and every way of carrying styles. -// emptyTitle is the title a page carries until a label fills it in. -const emptyTitle = `` +// placeholderTitle is the title a page carries when no label fills it in: at +// the smallest sizes, and in every page under --clean. Not empty, because the +// standard asks a title for text and the Nu HTML Checker calls an empty one an +// error (O277). A word that says nothing about this tool, because --clean is +// there to keep the tool's description out of the file +// (docs/HTML-6-2026-10-15.md). +const placeholderTitle = `Page` // byteOrderMark is the UTF-8 mark, the only one a page may carry: the standard // requires UTF-8 (docs/HTML-STRUCTURE-2026-09-08.md section 1). Written as its diff --git a/internal/guard/emlnewsletter_test.go b/internal/guard/emlnewsletter_test.go index 07f0c3a1..c7ec93b3 100644 --- a/internal/guard/emlnewsletter_test.go +++ b/internal/guard/emlnewsletter_test.go @@ -135,6 +135,10 @@ func TestTheEMLCheckRefusesANewsletterBrokenOnPurpose(t *testing.T) { ".frame { width: 600px; background: #ffffff; }\r\n"+ ".button { background: #1a73e8; color: #ffffff; text-decoration: none; }\r\n", "