From 3e93797434e2fd0bd295514aff0dbb449a2aa63d Mon Sep 17 00:00:00 2001 From: Frank Niessink Date: Wed, 23 Sep 2026 20:55:59 +0200 Subject: [PATCH] Strip non-breaking spaces from changelog headers Show a changelog heading that opens with non-breaking spaces without indenting it. Closes #369. --- CHANGELOG.md | 1 + src/update_time/io/console.py | 20 ++++++++++++++--- tests/update_time/io/test_console.py | 33 ++++++++++++++++++++++++++++ tools/prose-whitelist.txt | 2 ++ tools/vulture-whitelist.py | 2 +- 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 860e0b8..107bce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### Fixed - Show a changelog that opens with a list, a quote, or a table without a blank line above it. Closes [#368](https://github.com/ICTU/update-time/issues/368). +- Show a changelog heading that opens with non-breaking spaces without indenting it. Closes [#369](https://github.com/ICTU/update-time/issues/369). ## 0.0.38 - 2026-09-18 diff --git a/src/update_time/io/console.py b/src/update_time/io/console.py index 3bdeacd..150c28b 100644 --- a/src/update_time/io/console.py +++ b/src/update_time/io/console.py @@ -2,6 +2,7 @@ import logging import re +from itertools import pairwise from logging import WARNING from typing import TYPE_CHECKING @@ -140,18 +141,31 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR class _ChangelogMarkdown(Markdown): - """Markdown that shows raw HTML, renders shortcodes as emoji, and drops Rich's line break above its first block.""" + """Markdown that shows raw HTML, renders shortcodes as emoji, unindents headings, and drops a leading line break.""" elements: ClassVar = {**Markdown.elements, "html_block": _RawHtml} def __init__(self, markup: str, *, hyperlinks: bool) -> None: - """Parse the changes, replacing the shortcodes in every token the reader reads as text.""" + """Parse the changes, then replace their shortcodes and unindent their headings.""" super().__init__(markup, hyperlinks=hyperlinks) - for token in self.parsed: + self._replace_shortcodes(self.parsed) + self._unindent_headings(self.parsed) + + @staticmethod + def _replace_shortcodes(tokens: list[Token]) -> None: + """Replace the shortcodes written in prose or raw HTML with their emoji.""" + for token in tokens: for part in (token, *(token.children or ())): if part.type in _READ_AS_TEXT: part.content = Emoji.replace(part.content) + @staticmethod + def _unindent_headings(tokens: list[Token]) -> None: + """Strip the non-breaking spaces opening a heading's title, since Markdown strips only the ordinary ones.""" + for opening, title in pairwise(tokens): + if opening.type == "heading_open" and title.children and title.children[0].type == "text": + title.children[0].content = title.children[0].content.lstrip() + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: """Render the changes, without the line break Rich writes above the list, quote, or table that opens them.""" for index, segment in enumerate(super().__rich_console__(console, options)): diff --git a/tests/update_time/io/test_console.py b/tests/update_time/io/test_console.py index 6839e1b..6379046 100644 --- a/tests/update_time/io/test_console.py +++ b/tests/update_time/io/test_console.py @@ -196,6 +196,39 @@ def test_changes_render_as_markdown(self): self.assertIn("PyPI page: coverage 7.16.0", rendered) self.assertNotIn("[coverage 7.16.0]", rendered) + @kills( + Mutation( + console_module._ChangelogMarkdown._unindent_headings, + "title.children[0].content.lstrip()", + "title.children[0].content", + "a heading opening with non-breaking spaces is indented by them", + ), + Mutation( + console_module._ChangelogMarkdown._unindent_headings, + ' and title.children[0].type == "text":', + ":", + "a code span opening a heading loses the spaces the project wrote in it", + ), + Mutation( + console_module._ChangelogMarkdown._unindent_headings, + "title.children[0].content.lstrip()", + 'title.children[0].content.replace("\\xa0", "")', + "a heading loses the non-breaking spaces inside its title, running its words together", + ), + ) + def test_a_heading_drops_only_the_non_breaking_spaces_opening_its_text(self): + """Test that a heading keeps its spaces, in code or out, except the non-breaking ones opening its title.""" + cases = { + "text": ("###    ðŸš€ Features", "🚀 Features"), + "link": ("#####     [View changes on GitHub](https://github.com)", "View changes"), + "code": ("### ` indented()`", " indented()"), + "inside": ("###  A B", "A\xa0B"), + } + for case, (markup, title) in cases.items(): + with self.subTest(case=case): + rendered = self.rendered_changes(Changes(markup, markdown=True)) + self.assertIn(f"{_BOX_SIDE} {title}", rendered) + @kills( Mutation( console_module, diff --git a/tools/prose-whitelist.txt b/tools/prose-whitelist.txt index b5ff5cb..eac52a6 100644 --- a/tools/prose-whitelist.txt +++ b/tools/prose-whitelist.txt @@ -1,5 +1,7 @@ "held" stayed at 1.0 (e.g. min-release-age held back every newer release), "untracked" has no version:. "held" stayed at 1.0 (e.g. minimumReleaseAge held back every newer release), "untracked" has no version:. +###    ðŸš€ Features. +#####     [View changes on GitHub](https://github.com). **In an `update-time` field**, for a reference in a file that can hold no comment. - the sample log lines the sections below quote, from `log_samples`, each filling the placeholder it names. A CVSS vector the `cvss` package refuses, since `AV:X` is not a value the metric can take. diff --git a/tools/vulture-whitelist.py b/tools/vulture-whitelist.py index 235694d..ec32923 100644 --- a/tools/vulture-whitelist.py +++ b/tools/vulture-whitelist.py @@ -3,7 +3,7 @@ _.StartElementHandler # unused attribute (src/update_time/formats/xml.py:85) _.CharacterDataHandler # unused attribute (src/update_time/formats/xml.py:86) _.EndElementHandler # unused attribute (src/update_time/formats/xml.py:87) -_.create # unused method (src/update_time/io/console.py:120) +_.create # unused method (src/update_time/io/console.py:122) tag_last_pushed # unused variable (src/update_time/sources/docker_hub.py:59) download_url # unused variable (src/update_time/sources/github.py:90) database_specific # unused variable (src/update_time/sources/osv.py:52)