From 4e8ee91ae30591fffd73de7084c7a7c64f25c6b2 Mon Sep 17 00:00:00 2001 From: Justin Mayer Date: Sun, 11 Oct 2026 21:15:31 +0200 Subject: [PATCH] fix: Skip content whose empty slug is used in paths A title made only of punctuation, such as "???", slugifies to an empty string. When URL patterns use `{slug}`, the content was written to ".html", and two such articles crashed the build. Skip content with such titles with an error that suggests setting a slug. Patterns that don't use the slug, such as date-based `ARTICLE_SAVE_AS` patterns, are unaffected. --- pelican/contents.py | 24 +++++++++++++++++ pelican/tests/test_contents.py | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/pelican/contents.py b/pelican/contents.py index bce45135..9254701b 100644 --- a/pelican/contents.py +++ b/pelican/contents.py @@ -179,6 +179,29 @@ class Content: return False return True + def _has_valid_slug(self) -> bool: + """Return false if the slug is empty (e.g., for a title made only of + punctuation) but used in the URL or save_as, which would produce file + names such as ".html".""" + # An empty title is already reported as a missing mandatory property. + if getattr(self, "slug", None) != "" or not getattr(self, "title", None): + return True + + # Detect whether the slug is used by expanding with a placeholder. + paths = (self.url, self.save_as) + self.slug = "slug" + try: + uses_slug = (self.url, self.save_as) != paths + finally: + self.slug = "" + if not uses_slug: + return True + + logger.error( + "Skipping %s: its slug is empty; set a 'slug' in its metadata", self + ) + return False + def _has_valid_save_as(self) -> bool: """Return true if save_as doesn't write outside output path, false otherwise.""" @@ -220,6 +243,7 @@ class Content: return all( [ self._has_valid_mandatory_properties(), + self._has_valid_slug(), self._has_valid_save_as(), self._has_valid_status(), ] diff --git a/pelican/tests/test_contents.py b/pelican/tests/test_contents.py index cec07982..9dfa53f0 100644 --- a/pelican/tests/test_contents.py +++ b/pelican/tests/test_contents.py @@ -97,6 +97,54 @@ class TestPage(TestBase): page = Page("content", metadata={"title": "foobar"}) self.assertTrue(page._has_valid_mandatory_properties()) + def test_empty_slug_invalid(self): + # A title made only of punctuation produces an empty slug, which would + # otherwise result in a file named ".html". + page = Page("content", metadata={"title": "???"}) + self.assertEqual(page.slug, "") + self.assertFalse(page.is_valid()) + self.assertLogCountEqual( + count=1, + msg="Skipping .*: its slug is empty; set a 'slug' in its metadata", + level=logging.ERROR, + ) + + # An explicit slug, or a URL and save_as that don't use the slug, avoid + # the empty file name. + for metadata in ( + {"slug": "question-marks"}, + {"save_as": "q/index.html", "url": "q/"}, + ): + with self.subTest(metadata=metadata): + page = Page("content", metadata={"title": "???", **metadata}) + self.assertTrue(page.is_valid()) + settings = get_settings( + ARTICLE_URL="{date:%Y}/{date:%m}/{date:%d}/", + ARTICLE_SAVE_AS="{date:%Y}/{date:%m}/{date:%d}/index.html", + ) + article = Article( + "content", + metadata={"title": "???", "date": datetime.datetime(2026, 2, 9)}, + settings=settings, + ) + self.assertTrue(article.is_valid()) + + # Overriding save_as alone still leaves the URL as ".html". + page = Page("content", metadata={"title": "???", "save_as": "q.html"}) + self.assertFalse(page.is_valid()) + self.assertLogCountEqual( + count=2, + msg="Skipping .*: its slug is empty; set a 'slug' in its metadata", + level=logging.ERROR, + ) + + # An empty title is only reported as a missing title. + page = Page("content", metadata={"title": ""}) + self.assertFalse(page.is_valid()) + self.assertLogCountEqual( + count=2, msg="Skipping .*: its slug is empty.*", level=logging.ERROR + ) + def test_summary_from_metadata(self): # If a :summary: metadata is given, it should be used page = Page(**self.page_kwargs)