fix: Always assign DEFAULT_CATEGORY

PR #3589 tied category assignment to `CATEGORY_SAVE_AS`, so disabling
category pages also removed article categories and broke `{category}`
in URL patterns (#3596). PR #3597 restored folder-based categories, but
articles outside a subfolder still lost `DEFAULT_CATEGORY`. This change
assigns it again, so `CATEGORY_SAVE_AS` only controls whether category
pages are generated. Setting `DEFAULT_CATEGORY = None` is now the
documented way to have no categories.

This also stops the Simple and NotMyIdea themes from linking to category
pages that don't exist.
This commit is contained in:
Justin Mayer 2026-10-11 19:01:27 +02:00
commit edd1f37cd4
9 changed files with 101 additions and 19 deletions

View file

@ -65,6 +65,10 @@ Basic settings
The default category to fall back on. The default is ``"misc"``.
To publish articles without any category, set ``DEFAULT_CATEGORY = None``
and ``USE_FOLDER_AS_CATEGORY = False``. Articles will then only have a
category if one is specified in their metadata.
.. data:: DISPLAY_PAGES_ON_MENU
Whether to display pages on the menu of the template. Templates may or may
@ -590,6 +594,8 @@ This would save your articles into something like
.. data:: CATEGORY_SAVE_AS
The location to save a category. The default is ``"category/{slug}.html"``.
Set this to ``""`` to disable category pages. Articles still keep their
categories, which can be used in URL patterns such as ``ARTICLE_SAVE_AS``.
.. data:: TAG_URL

View file

@ -82,9 +82,9 @@ def ensure_metadata_list(text):
def _process_if_nonempty(processor, name, settings):
"""Removes extra whitespace from name and applies a metadata processor.
If name is empty or all whitespace, returns _DISCARD instead.
If name is None, empty, or all whitespace, returns _DISCARD instead.
"""
name = name.strip()
name = (name or "").strip()
return processor(name, settings) if name else _DISCARD
@ -746,7 +746,7 @@ def default_metadata(settings=None, process=None):
if process:
value = process(name, value)
metadata[name] = value
if "DEFAULT_CATEGORY" in settings and settings.get("CATEGORY_SAVE_AS"):
if "DEFAULT_CATEGORY" in settings:
value = settings["DEFAULT_CATEGORY"]
if process:
value = process("category", value)

View file

@ -405,7 +405,7 @@ class TestArticlesGenerator(unittest.TestCase):
settings = get_settings(PATH=content_dir)
settings["CACHE_PATH"] = self.temp_cache
settings["USE_FOLDER_AS_CATEGORY"] = False
settings["CATEGORY_SAVE_AS"] = None # No default category fallback
settings["DEFAULT_CATEGORY"] = None # No default category fallback
context = get_context(settings)
generator = ArticlesGenerator(

View file

@ -161,16 +161,44 @@ class DefaultReaderTest(ReaderTest):
)
def test_path_metadata_categories(self):
"""Ensure folder names become categories even when category pages are
disabled, so {category} still works in URL patterns."""
nested_content_path = os.path.join(CUR_DIR, "nested_content")
r = readers.Readers(
settings=get_settings(USE_FOLDER_AS_CATEGORY=True, CATEGORY_SAVE_AS=None)
)
mainpage = r.read_file(base_path=nested_content_path, path="maindir/maindir.md")
self.assertDictHasSubset(mainpage.metadata, {"category": "maindir"})
subpage = r.read_file(
base_path=nested_content_path, path="maindir/subdir/subdir.md"
)
self.assertDictHasSubset(subpage.metadata, {"category": "subdir"})
for category_save_as in ("", None, False):
with self.subTest(CATEGORY_SAVE_AS=category_save_as):
r = readers.Readers(
settings=get_settings(
USE_FOLDER_AS_CATEGORY=True, CATEGORY_SAVE_AS=category_save_as
)
)
mainpage = r.read_file(
base_path=nested_content_path, path="maindir/maindir.md"
)
self.assertDictHasSubset(mainpage.metadata, {"category": "maindir"})
subpage = r.read_file(
base_path=nested_content_path, path="maindir/subdir/subdir.md"
)
self.assertDictHasSubset(subpage.metadata, {"category": "subdir"})
def test_default_category_when_category_pages_disabled(self):
for category_save_as in ("", None, False):
with self.subTest(CATEGORY_SAVE_AS=category_save_as):
page = self.read_file(
path="article.rst",
DEFAULT_CATEGORY="misc",
CATEGORY_SAVE_AS=category_save_as,
)
self.assertEqual(page.metadata["category"], "misc")
def test_empty_default_category_omits_category(self):
for default_category in ("", None):
with self.subTest(DEFAULT_CATEGORY=default_category):
page = self.read_file(
path="article.rst",
DEFAULT_CATEGORY=default_category,
USE_FOLDER_AS_CATEGORY=False,
)
self.assertNotIn("category", page.metadata)
class RstReaderTest(ReaderTest):

View file

@ -134,8 +134,8 @@ class TestTemplateInheritance(LoggedTestCase):
self.assertIn("New footer", content)
def test_disabled_categories_no_links(self):
"""When CATEGORY_SAVE_AS is empty, articles without an explicit
category should not get a default category or show category links."""
"""When DEFAULT_CATEGORY is empty, articles without an explicit
category should not show a category."""
# Create an article without a Category metadata line
content_dir = mkdtemp(prefix="pelican_test_content.")
@ -155,6 +155,8 @@ class TestTemplateInheritance(LoggedTestCase):
"OUTPUT_PATH": self.temp_output,
"CACHE_PATH": self.temp_cache,
"SITEURL": "http://example.com",
"DEFAULT_CATEGORY": "",
"USE_FOLDER_AS_CATEGORY": False,
"CATEGORY_SAVE_AS": None,
"CATEGORY_URL": None,
"CATEGORIES_SAVE_AS": None,
@ -171,6 +173,45 @@ class TestTemplateInheritance(LoggedTestCase):
finally:
rmtree(content_dir)
def test_disabled_category_pages_no_links(self):
"""When CATEGORY_SAVE_AS is empty, articles keep their category, but
themes should not link to category pages that are not generated."""
content_dir = mkdtemp(prefix="pelican_test_content.")
os.makedirs(os.path.join(content_dir, "blog"))
with open(os.path.join(content_dir, "blog", "in_blog.md"), "w") as f:
f.write("Title: In Blog\nDate: 2024-01-01\n\nArticle in a folder.\n")
try:
for theme in ("simple", "notmyidea"):
with self.subTest(theme=theme):
settings = read_settings(
path=None,
override={
"THEME": theme,
"PATH": content_dir,
"OUTPUT_PATH": self.temp_output,
"CACHE_PATH": self.temp_cache,
"SITEURL": "http://example.com",
"ARTICLE_SAVE_AS": "{category}/{slug}.html",
"CATEGORY_SAVE_AS": False,
"CATEGORIES_SAVE_AS": False,
},
)
pelican = Pelican(settings=settings)
mute(True)(pelican.run)()
with open(
os.path.join(self.temp_output, "blog", "in-blog.html")
) as f:
content = f.read()
self.assertIn("blog", content)
self.assertNotIn("/category/", content)
finally:
rmtree(content_dir)
def test_category_and_tag_feed_titles_use_slug(self):
"""Feed link titles on category/tag pages should have unique titles."""

View file

@ -16,7 +16,9 @@
{% endfor %}
</address>
{% endif %}
<p>In <a href="{{ SITEURL }}/{{ article.category.url }}">{{ article.category }}</a>.</p>
{% if article.category %}
<p>In {% if CATEGORY_SAVE_AS %}<a href="{{ SITEURL }}/{{ article.category.url }}">{{ article.category }}</a>{% else %}{{ article.category }}{% endif %}.</p>
{% endif %}
{% include 'taglist.html' %}
{% import 'translations.html' as translations with context %}
{{ translations.translations_for(article) }}

View file

@ -30,7 +30,7 @@
<li{% if pg == page %} class="active"{% endif %}><a href="{{ SITEURL }}/{{ pg.url }}">{{ pg.title }}</a></li>
{% endfor %}
{% endif %}
{% if DISPLAY_CATEGORIES_ON_MENU -%}
{% if DISPLAY_CATEGORIES_ON_MENU and CATEGORY_SAVE_AS -%}
{% for cat, null in categories %}
<li{% if cat == category %} class="active"{% endif %}><a href="{{ SITEURL }}/{{ cat.url }}">{{ cat }}</a></li>
{% endfor %}

View file

@ -49,7 +49,12 @@
{% endif %}
{% if article.category %}
<p>
Category: <a href="{{ SITEURL }}/{{ article.category.url }}">{{ article.category }}</a>
Category:
{% if CATEGORY_SAVE_AS %}
<a href="{{ SITEURL }}/{{ article.category.url }}">{{ article.category }}</a>
{% else %}
{{ article.category }}
{% endif %}
</p>
{% endif %}
{% if article.tags %}

View file

@ -58,7 +58,7 @@
<li><a href="{{ SITEURL }}/{{ p.url }}" {% if p==page %} aria-current="page" {% endif %}>{{ p.title }}</a></li>
{% endfor %}
{% endif %}
{% if DISPLAY_CATEGORIES_ON_MENU %}
{% if DISPLAY_CATEGORIES_ON_MENU and CATEGORY_SAVE_AS %}
{% for cat, null in categories %}
<li><a href="{{ SITEURL }}/{{ cat.url }}" {% if cat==category %} aria-current="page" {% endif %}>{{ cat}}</a></li>
{% endfor %}