Repository: getnikola/nikola

Issue:
reST doc directive: slugs with slashes in no longer work
At some point in the previous few versions, slugs with slashes in no longer work - links pointing to them have their slashes removed before the link is resolved, leading to an error like `"swowndata" slug doesn't exist` for a slug called `sw/owndata`. I can't find this being mentioned in the release notes or the other issues here, so I assume this was done by accident, is a bug, and should be fixed.

Comments:

Patch:
diff --git a/AUTHORS.txt b/AUTHORS.txt
index 6ff3ba700..59bb90de6 100644
--- a/AUTHORS.txt
+++ b/AUTHORS.txt
@@ -44,6 +44,7 @@
 * `Felix Fontein <https://github.com/felixfontein>`_
 * `Felix Schwarz <https://github.com/FelixSchwarz>`_
 * `Florian Finkernagel <https://github.com/TyberiusPrime>`_
+* `Florian Zimmermann <https://github.com/PoByBolek>`_
 * `follower <https://github.com/follower>`_
 * `George Leslie-Waksman <https://github.com/gwax>`_
 * `Grzegorz Śliwiński <https://github.com/fizyk>`_
diff --git a/CHANGES.txt b/CHANGES.txt
index 80c9d2b2b..30377d727 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -16,6 +16,7 @@ Features
 Bugfixes
 --------
 
+* Don’t break slugs with slashes in ``doc`` directive (Issue #3450)
 * Avoid warnings from type annotations in ``auto`` caused by missing
   ``aiohttp`` (Issue #3451)
 * Ensure query strings and fragments are kept with ``URL_TYPE =
@@ -25,6 +26,7 @@ Bugfixes
 * Fix handling of duplicate plugins on Windows
 * Allow else clause in post-list plugin. (Issue #3436)
 * Ensure `type` metadata value from plugins is preserved (Issue 3445)
+* Fix path handlers with slashes on Windows
 
 New in v8.1.1
 =============
diff --git a/nikola/plugins/compile/rest/doc.py b/nikola/plugins/compile/rest/doc.py
index c52168c12..705c0bcbf 100644
--- a/nikola/plugins/compile/rest/doc.py
+++ b/nikola/plugins/compile/rest/doc.py
@@ -47,16 +47,8 @@ class Plugin(RestExtension):
         return super().set_site(site)
 
 
-def _doc_link(rawtext, text, options={}, content=[]):
-    """Handle the doc role."""
-    # split link's text and post's slug in role content
-    has_explicit_title, title, slug = split_explicit_title(text)
-    if '#' in slug:
-        slug, fragment = slug.split('#', 1)
-    else:
-        fragment = None
-    slug = slugify(slug)
-    # check if the slug given is part of our blog posts/pages
+def _find_post(slug):
+    """Find a post with the given slug in posts or pages."""
     twin_slugs = False
     post = None
     for p in doc_role.site.timeline:
@@ -66,6 +58,23 @@ def _doc_link(rawtext, text, options={}, content=[]):
             else:
                 twin_slugs = True
                 break
+    return post, twin_slugs
+
+
+def _doc_link(rawtext, text, options={}, content=[]):
+    """Handle the doc role."""
+    # split link's text and post's slug in role content
+    has_explicit_title, title, slug = split_explicit_title(text)
+    if '#' in slug:
+        slug, fragment = slug.split('#', 1)
+    else:
+        fragment = None
+
+    # Look for the unslugified input first, then try to slugify (Issue #3450)
+    post, twin_slugs = _find_post(slug)
+    if post is None:
+        slug = slugify(slug)
+        post, twin_slugs = _find_post(slug)
 
     try:
         if post is None:
diff --git a/nikola/plugins/task/authors.py b/nikola/plugins/task/authors.py
index 4aa5b410b..e0245d5ec 100644
--- a/nikola/plugins/task/authors.py
+++ b/nikola/plugins/task/authors.py
@@ -94,7 +94,8 @@ link://author_rss/joe => /authors/joe.xml""",
 
     def get_overview_path(self, lang, dest_type='page'):
         """Return a path for the list of all classifications."""
-        return [self.site.config['AUTHOR_PATH'](lang)], 'always'
+        path = self.site.config['AUTHOR_PATH'](lang)
+        return [component for component in path.split('/') if component], 'always'
 
     def get_path(self, classification, lang, dest_type='page'):
         """Return a path for the given classification."""
diff --git a/nikola/plugins/task/categories.py b/nikola/plugins/task/categories.py
index 323747e13..68f9caa2d 100644
--- a/nikola/plugins/task/categories.py
+++ b/nikola/plugins/task/categories.py
@@ -112,9 +112,11 @@ link://category_rss/dogs => /categories/dogs.xml""",
         """Return a path for the list of all classifications."""
         if self.site.config['CATEGORIES_INDEX_PATH'](lang):
             path = self.site.config['CATEGORIES_INDEX_PATH'](lang)
-            return [_f for _f in [path] if _f], 'never'
+            append_index = 'never'
         else:
-            return [_f for _f in [self.site.config['CATEGORY_PATH'](lang)] if _f], 'always'
+            path = self.site.config['CATEGORY_PATH'](lang)
+            append_index = 'always'
+        return [component for component in path.split('/') if component], append_index
 
     def slugify_tag_name(self, name, lang):
         """Slugify a tag name."""
diff --git a/nikola/plugins/task/tags.py b/nikola/plugins/task/tags.py
index 1726d88b4..aecf8f591 100644
--- a/nikola/plugins/task/tags.py
+++ b/nikola/plugins/task/tags.py
@@ -102,9 +102,11 @@ link://tag_rss/cats => /tags/cats.xml""",
         """Return a path for the list of all classifications."""
         if self.site.config['TAGS_INDEX_PATH'](lang):
             path = self.site.config['TAGS_INDEX_PATH'](lang)
-            return [_f for _f in [path] if _f], 'never'
+            append_index = 'never'
         else:
-            return [_f for _f in [self.site.config['TAG_PATH'](lang)] if _f], 'always'
+            path = self.site.config['TAG_PATH'](lang)
+            append_index = 'always'
+        return [component for component in path.split('/') if component], append_index
 
     def get_path(self, classification, lang, dest_type='page'):
         """Return a path for the given classification."""


Test patch:
diff --git a/tests/test_path_handlers.py b/tests/test_path_handlers.py
new file mode 100644
index 000000000..23b6bf109
--- /dev/null
+++ b/tests/test_path_handlers.py
@@ -0,0 +1,114 @@
+"""Test that CATEGORIES_INDEX_PATH and TAGS_INDEX_PATH return the correct values on Unix and Windows."""
+from unittest import mock
+
+from nikola import Nikola
+from nikola.plugins.misc.taxonomies_classifier import TaxonomiesClassifier
+from nikola.plugins.task.authors import ClassifyAuthors
+from nikola.plugins.task.categories import ClassifyCategories
+from nikola.plugins.task.tags import ClassifyTags
+from nikola.plugins.task.taxonomies import RenderTaxonomies
+
+import pytest
+
+
+@pytest.fixture(params=[ClassifyAuthors, ClassifyCategories, ClassifyTags], ids=["authors", "categories", "tags"])
+def taxonomy(request):
+    return request.param()
+
+
+@pytest.fixture(params=[
+    "base:", "base:blog", "base:path/with/trailing/slash/", "base:/path/with/leading/slash",
+    "index:tags.html", "index:blog/tags.html", "index:path/to/tags.html", "index:/path/with/leading/slash.html",
+])
+def path(request):
+    return request.param
+
+
+@pytest.fixture
+def fixture(taxonomy, path):
+    scheme, _, path = path.partition(':')
+    append_index = scheme == 'base'
+    if isinstance(taxonomy, ClassifyAuthors) and append_index:
+        site = Nikola(TRANSLATIONS={"en": ""}, AUTHOR_PATH=path)
+    elif isinstance(taxonomy, ClassifyAuthors) and not append_index:
+        pytest.skip("There is no AUTHORS_INDEX_PATH setting")
+    elif isinstance(taxonomy, ClassifyCategories) and append_index:
+        site = Nikola(TRANSLATIONS={"en": ""}, CATEGORY_PATH=path)
+    elif isinstance(taxonomy, ClassifyCategories) and not append_index:
+        site = Nikola(TRANSLATIONS={"en": ""}, CATEGORIES_INDEX_PATH=path)
+    elif isinstance(taxonomy, ClassifyTags) and append_index:
+        site = Nikola(TRANSLATIONS={"en": ""}, TAG_PATH=path)
+    elif isinstance(taxonomy, ClassifyTags) and not append_index:
+        site = Nikola(TRANSLATIONS={"en": ""}, TAGS_INDEX_PATH=path)
+    else:
+        raise TypeError("Unknown taxonomy %r" % type(taxonomy))
+
+    site._template_system = mock.MagicMock()
+    site._template_system.template_deps.return_value = []
+    site._template_system.name = "dummy"
+    site.hierarchy_per_classification = {taxonomy.classification_name: {"en": []}}
+    site.posts_per_classification = {taxonomy.classification_name: {"en": {}}}
+    site.taxonomy_plugins = {taxonomy.classification_name: taxonomy}
+
+    taxonomy.set_site(site)
+
+    classifier = TaxonomiesClassifier()
+    classifier.set_site(site)
+
+    expected = path.strip("/")
+    if append_index:
+        expected += "/"
+    if not expected.startswith("/"):
+        expected = "/" + expected
+
+    return site, classifier, taxonomy, append_index, expected
+
+
+def test_render_taxonomies_permalink(fixture):
+    # Arrange
+    site, _, taxonomy, _, expected = fixture
+    renderer = RenderTaxonomies()
+    renderer.set_site(site)
+
+    # Act
+    tasks = list(renderer._generate_classification_overview(taxonomy, "en"))
+
+    # Assert
+    action, args = tasks[0]["actions"][0]
+    context = args[2]
+    assert context["permalink"] == expected
+
+
+def test_taxonomy_index_path_helper(fixture):
+    # Arrange
+    site, _, taxonomy, _, expected = fixture
+
+    # Act
+    path = site.path(taxonomy.classification_name + "_index", "name", "en", is_link=True)
+
+    # Assert
+    assert path == expected
+
+
+def test_taxonomy_classifier_index_path(fixture):
+    # Arrange
+    site, classifier, taxonomy, append_index, expected = fixture
+    if append_index:
+        expected += "index.html"
+
+    # Act
+    path = classifier._taxonomy_index_path("name", "en", taxonomy)
+
+    # Assert
+    assert path == [x for x in expected.split('/') if x]
+
+
+def test_taxonomy_overview_path(fixture):
+    # Arrange
+    _, _, taxonomy, append_index, expected = fixture
+
+    # Act
+    result = taxonomy.get_overview_path("en")
+
+    # Assert
+    assert result == ([x for x in expected.split('/') if x], "always" if append_index else "never")