Repository: stuartmaxwell/djpress

Issue:
Theme support
Implement themes.

- [X] Uploaded to `./templates/djpress/{{ theme_name }}`
- [X] Move the default theme to `./templates/djpress/default`
- [X] Selected with a setting: `DJPRESS_SETTINGS["THEME"] = "{{ theme_name }}"`
- [X] ~~If the configured theme doesn't exist, then fall back to default~~
- [X] If the configured theme doesn't exist, raise an error
- [x] Need to document the template loader order
- [X] Custom themes would need at least an `index.html` file
- [X] Static files…
  - [X] Themes could define any static resource, but the user would need to ensure it's copied to the correct static directory
  - [x] ~~Would be better to have the theme and static files in one folder, e.g. ~~`~~./templates/djpress/{{ theme_name }}/static/css~~`~~ etc.~~
  - [x] ~~Custom static paths? Is this possible? Yes, a custom loader is possible~~
  - [X] Perhaps recommend copying files manually for now, e.g. `./static/djpress/{{ theme_name }}/css/styles.css` etc. (avoid premature optimisation)

Enough details to get started...

Discussion:

Patch:
diff --git a/docs/themes.md b/docs/themes.md
new file mode 100644
index 0000000..4694a28
--- /dev/null
+++ b/docs/themes.md
@@ -0,0 +1,50 @@
+# Themes
+
+A DJ Press theme is a collection of one or more Django template files. and any static files required to style those
+templates.
+
+## Template Files
+
+Template files should be copied to: `./templates/djpress/{{ your_theme_name }}/`. At a minimum, the theme must include
+an `index.html` file. You can build an entire theme with just this one file, and an example can be seen in the DJ Press
+included theme called `default`.
+
+Other template files that can be included are as follows:
+
+- `home.html` - used on the home page view, i.e. `https://yourblog.com/`
+- `archives.html` - used to display the date-based archives views, e.g. `https://yourblog.com/2024/`
+- `category.html` - used to display blog posts in a particular category, e.g. `https://yourblog.com/category/news/`
+- `author.html` - used to display blog posts by a particular author, e.g. `https://yourblog.com/author/sam/`
+- `single.html` - used to display a single blog post, e.g. `https://yourblog.com/2024/09/30/my-interesting-blog-post/`
+- `page.html` - used to display a single page, e.g. `https://yourblog.com/colophon/`
+
+In all cases, if the above file is not found, the `index.html` page will be displayed instead.
+
+## Static Files
+
+Static files should be copied to: `./static/djpress/{{ your_theme_name }}/`. Static files are typically grouped into
+sub-directories for CSS or JavaScript or image files, e.g. `./static/djpress/{{ your_theme_name }}/css/` or
+`./static/djpress/{{ your_theme_name }}/js/` or `./static/djpress/{{ your_theme_name }}/img`, but these
+sub-directories are optional and up to the theme developer how or if to use them.
+
+From within the template files, static assets can be referenced using standard Django template tags, e.g.
+`{% static 'djpress/{{ your_theme_name }}/css/style.css' %}`, depending on the aforementioned directory structure.
+
+## Configure a Theme
+
+In your Django project settings file, configure the `THEME` setting in the `DJPRESS_SETTINGS` object, e.g.
+
+```python
+DJPRESS_SETTINGS = {
+  "THEME": "your_theme_name",
+}
+```
+
+In the example configuration, `your_theme_name` must match exactly the directory name that the theme's template files
+are copied to. If this directory cannot be found, or if there is no matching template file in it, your site will crash
+with a `TemplateDoesNotExist` error.
+
+## Examples
+
+There are currently two themes included in DJ Press: `default` and `simple`. The `default` theme demonstrates how to
+build a theme with just a single `index.html` file, whereas the `simple` theme uses all available template types.
diff --git a/src/djpress/app_settings.py b/src/djpress/app_settings.py
index f8352cc..e941c05 100644
--- a/src/djpress/app_settings.py
+++ b/src/djpress/app_settings.py
@@ -21,4 +21,5 @@ DJPRESS_SETTINGS = {
     "RSS_ENABLED": (True, bool),
     "RSS_PATH": ("rss", str),
     "MICROFORMATS_ENABLED": (True, bool),
+    "THEME": ("default", str),
 }
diff --git a/src/djpress/static/djpress/css/style.css b/src/djpress/static/default/css/style.css
similarity index 100%
rename from src/djpress/static/djpress/css/style.css
rename to src/djpress/static/default/css/style.css
diff --git a/src/djpress/static/djpress/css/style.min.css b/src/djpress/static/default/css/style.min.css
similarity index 100%
rename from src/djpress/static/djpress/css/style.min.css
rename to src/djpress/static/default/css/style.min.css
diff --git a/src/djpress/static/simple/css/simple.min.css b/src/djpress/static/simple/css/simple.min.css
new file mode 100644
index 0000000..3d3f19a
--- /dev/null
+++ b/src/djpress/static/simple/css/simple.min.css
@@ -0,0 +1,1 @@
+:root,::backdrop{--sans-font:-apple-system,BlinkMacSystemFont,"Avenir Next",Avenir,"Nimbus Sans L",Roboto,"Noto Sans","Segoe UI",Arial,Helvetica,"Helvetica Neue",sans-serif;--mono-font:Consolas,Menlo,Monaco,"Andale Mono","Ubuntu Mono",monospace;--standard-border-radius:5px;--bg:#fff;--accent-bg:#f5f7ff;--text:#212121;--text-light:#585858;--border:#898ea4;--accent:#0d47a1;--accent-hover:#1266e2;--accent-text:var(--bg);--code:#d81b60;--preformatted:#444;--marked:#fd3;--disabled:#efefef}@media (prefers-color-scheme:dark){:root,::backdrop{color-scheme:dark;--bg:#212121;--accent-bg:#2b2b2b;--text:#dcdcdc;--text-light:#ababab;--accent:#ffb300;--accent-hover:#ffe099;--accent-text:var(--bg);--code:#f06292;--preformatted:#ccc;--disabled:#111}img,video{opacity:.8}}*,:before,:after{box-sizing:border-box}textarea,select,input,progress{-webkit-appearance:none;-moz-appearance:none;appearance:none}html{font-family:var(--sans-font);scroll-behavior:smooth}body{color:var(--text);background-color:var(--bg);grid-template-columns:1fr min(45rem,90%) 1fr;margin:0;font-size:1.15rem;line-height:1.5;display:grid}body>*{grid-column:2}body>header{background-color:var(--accent-bg);border-bottom:1px solid var(--border);text-align:center;grid-column:1/-1;padding:0 .5rem 2rem}body>header>:only-child{margin-block-start:2rem}body>header h1{max-width:1200px;margin:1rem auto}body>header p{max-width:40rem;margin:1rem auto}main{padding-top:1.5rem}body>footer{color:var(--text-light);text-align:center;border-top:1px solid var(--border);margin-top:4rem;padding:2rem 1rem 1.5rem;font-size:.9rem}h1{font-size:3rem}h2{margin-top:3rem;font-size:2.6rem}h3{margin-top:3rem;font-size:2rem}h4{font-size:1.44rem}h5{font-size:1.15rem}h6{font-size:.96rem}p{margin:1.5rem 0}p,h1,h2,h3,h4,h5,h6{overflow-wrap:break-word}h1,h2,h3{line-height:1.1}@media only screen and (width<=720px){h1{font-size:2.5rem}h2{font-size:2.1rem}h3{font-size:1.75rem}h4{font-size:1.25rem}}a,a:visited{color:var(--accent)}a:hover{text-decoration:none}button,.button,a.button,input[type=submit],input[type=reset],input[type=button],label[type=button]{border:1px solid var(--accent);background-color:var(--accent);color:var(--accent-text);padding:.5rem .9rem;line-height:normal;text-decoration:none}.button[aria-disabled=true],input:disabled,textarea:disabled,select:disabled,button[disabled]{cursor:not-allowed;background-color:var(--disabled);border-color:var(--disabled);color:var(--text-light)}input[type=range]{padding:0}abbr[title]{cursor:help;text-decoration-line:underline;text-decoration-style:dotted}button:enabled:hover,.button:not([aria-disabled=true]):hover,input[type=submit]:enabled:hover,input[type=reset]:enabled:hover,input[type=button]:enabled:hover,label[type=button]:hover{background-color:var(--accent-hover);border-color:var(--accent-hover);cursor:pointer}.button:focus-visible,button:focus-visible:where(:enabled),input:enabled:focus-visible:where([type=submit],[type=reset],[type=button]){outline:2px solid var(--accent);outline-offset:1px}header>nav{padding:1rem 0 0;font-size:1rem;line-height:2}header>nav ul,header>nav ol{flex-flow:wrap;place-content:space-around center;align-items:center;margin:0;padding:0;list-style-type:none;display:flex}header>nav ul li,header>nav ol li{display:inline-block}header>nav a,header>nav a:visited{border:1px solid var(--border);border-radius:var(--standard-border-radius);color:var(--text);margin:0 .5rem 1rem;padding:.1rem 1rem;text-decoration:none;display:inline-block}header>nav a:hover,header>nav a.current,header>nav a[aria-current=page],header>nav a[aria-current=true]{border-color:var(--accent);color:var(--accent);cursor:pointer}@media only screen and (width<=720px){header>nav a{border:none;padding:0;line-height:1;text-decoration:underline}}aside,details,pre,progress{background-color:var(--accent-bg);border:1px solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem}aside{float:right;width:30%;margin-inline-start:15px;padding:0 15px;font-size:1rem}[dir=rtl] aside{float:left}@media only screen and (width<=720px){aside{float:none;width:100%;margin-inline-start:0}}article,fieldset,dialog{border:1px solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem;padding:1rem}article h2:first-child,section h2:first-child,article h3:first-child,section h3:first-child{margin-top:1rem}section{border-top:1px solid var(--border);border-bottom:1px solid var(--border);margin:3rem 0;padding:2rem 1rem}section+section,section:first-child{border-top:0;padding-top:0}section+section{margin-top:0}section:last-child{border-bottom:0;padding-bottom:0}details{padding:.7rem 1rem}summary{cursor:pointer;word-break:break-all;margin:-.7rem -1rem;padding:.7rem 1rem;font-weight:700}details[open]>summary+*{margin-top:0}details[open]>summary{margin-bottom:.5rem}details[open]>:last-child{margin-bottom:0}table{border-collapse:collapse;margin:1.5rem 0}figure>table{width:max-content;margin:0}td,th{border:1px solid var(--border);text-align:start;padding:.5rem}th{background-color:var(--accent-bg);font-weight:700}tr:nth-child(2n){background-color:var(--accent-bg)}table caption{margin-bottom:.5rem;font-weight:700}textarea,select,input,button,.button{font-size:inherit;border-radius:var(--standard-border-radius);box-shadow:none;max-width:100%;margin-bottom:.5rem;padding:.5rem;font-family:inherit;display:inline-block}textarea,select,input{color:var(--text);background-color:var(--bg);border:1px solid var(--border)}label{display:block}textarea:not([cols]){width:100%}select:not([multiple]){background-image:linear-gradient(45deg,transparent 49%,var(--text)51%),linear-gradient(135deg,var(--text)51%,transparent 49%);background-position:calc(100% - 15px),calc(100% - 10px);background-repeat:no-repeat;background-size:5px 5px,5px 5px;padding-inline-end:25px}[dir=rtl] select:not([multiple]){background-position:10px,15px}input[type=checkbox],input[type=radio]{vertical-align:middle;width:min-content;position:relative}input[type=checkbox]+label,input[type=radio]+label{display:inline-block}input[type=radio]{border-radius:100%}input[type=checkbox]:checked,input[type=radio]:checked{background-color:var(--accent)}input[type=checkbox]:checked:after{content:" ";border-right:solid var(--bg).08em;border-bottom:solid var(--bg).08em;background-color:#0000;border-radius:0;width:.18em;height:.32em;font-size:1.8em;position:absolute;top:.05em;left:.17em;transform:rotate(45deg)}input[type=radio]:checked:after{content:" ";background-color:var(--bg);border-radius:100%;width:.25em;height:.25em;font-size:32px;position:absolute;top:.125em;left:.125em}@media only screen and (width<=720px){textarea,select,input{width:100%}}input[type=color]{height:2.5rem;padding:.2rem}input[type=file]{border:0}hr{background:var(--border);border:none;height:1px;margin:1rem auto}mark{border-radius:var(--standard-border-radius);background-color:var(--marked);color:#000;padding:2px 5px}mark a{color:#0d47a1}img,video{border-radius:var(--standard-border-radius);max-width:100%;height:auto}figure{margin:0;display:block;overflow-x:auto}figure>img,figure>picture>img{margin-inline:auto;display:block}figcaption{text-align:center;color:var(--text-light);margin-block:1rem;font-size:.9rem}blockquote{border-inline-start:.35rem solid var(--accent);color:var(--text-light);margin-block:2rem;margin-inline:2rem 0;padding:.4rem .8rem;font-style:italic}cite{color:var(--text-light);font-size:.9rem;font-style:normal}dt{color:var(--text-light)}code,pre,pre span,kbd,samp{font-family:var(--mono-font);color:var(--code)}kbd{color:var(--preformatted);border:1px solid var(--preformatted);border-bottom:3px solid var(--preformatted);border-radius:var(--standard-border-radius);padding:.1rem .4rem}pre{color:var(--preformatted);max-width:100%;padding:1rem 1.4rem;overflow:auto}pre code{color:var(--preformatted);background:0 0;margin:0;padding:0}progress{width:100%}progress:indeterminate{background-color:var(--accent-bg)}progress::-webkit-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent-bg)}progress::-webkit-progress-value{border-radius:var(--standard-border-radius);background-color:var(--accent)}progress::-moz-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent);transition-property:width;transition-duration:.3s}progress:indeterminate::-moz-progress-bar{background-color:var(--accent-bg)}dialog{max-width:40rem;margin:auto}dialog::backdrop{background-color:var(--bg);opacity:.8}@media only screen and (width<=720px){dialog{max-width:100%;margin:auto 1em}}sup,sub{vertical-align:baseline;position:relative}sup{top:-.4em}sub{top:.3em}.notice{background:var(--accent-bg);border:2px solid var(--border);border-radius:var(--standard-border-radius);margin:2rem 0;padding:1.5rem}
diff --git a/src/djpress/static/simple/css/style.css b/src/djpress/static/simple/css/style.css
new file mode 100644
index 0000000..66e8b1c
--- /dev/null
+++ b/src/djpress/static/simple/css/style.css
@@ -0,0 +1,30 @@
+body {
+  display: grid;
+  grid-template-columns: 0.6fr 1.4fr;
+  grid-template-rows: min-content 1.8fr min-content;
+  gap: 0px 0px;
+  grid-auto-flow: row;
+  grid-template-areas:
+  "Header Header"
+  "Sidebar Content"
+  "Footer Footer";
+  width: 80%;
+  height: 100%;
+  margin: 0 auto;
+}
+
+header {
+  grid-area: Header;
+}
+
+nav {
+  grid-area: Sidebar;
+}
+
+main {
+  grid-area: Content;
+}
+
+footer {
+  grid-area: Footer;
+}
diff --git a/src/djpress/templates/djpress/index.html b/src/djpress/templates/djpress/default/index.html
similarity index 96%
rename from src/djpress/templates/djpress/index.html
rename to src/djpress/templates/djpress/default/index.html
index eaba0a3..6e565c5 100644
--- a/src/djpress/templates/djpress/index.html
+++ b/src/djpress/templates/djpress/default/index.html
@@ -7,7 +7,7 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>{% blog_page_title post_text="| " %}{% blog_title %}</title>
-    <link rel="stylesheet" href="{% static 'djpress/css/style.css' %}">
+    <link rel="stylesheet" href="{% static 'default/css/style.css' %}">
     <link rel="alternate" type="application/rss+xml" title="Latest Posts" href="{% rss_url %}">
   </head>
   <body>
diff --git a/src/djpress/templates/djpress/simple/index.html b/src/djpress/templates/djpress/simple/index.html
new file mode 100644
index 0000000..d518462
--- /dev/null
+++ b/src/djpress/templates/djpress/simple/index.html
@@ -0,0 +1,86 @@
+{% load static %}
+{% load djpress_tags %}
+
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{% blog_page_title post_text="| " %}{% blog_title %}</title>
+    <link rel="stylesheet" href="{% static "simple/css/simple.min.css" %}">
+    <link rel="stylesheet" href="{% static "simple/css/style.css" %}">
+  </head>
+  <body>
+    <header>
+      <h1>{% blog_title_link %}</h1>
+      <nav>
+        {% blog_pages %}
+      </nav>
+    </header>
+
+    <nav>
+      <h3>Recent Posts:</h3>
+      <ul>
+        {% get_recent_posts as posts %}
+        {% for post in posts %}
+          <li><a href="{{ post.url }}">{{ post.title }}</a></li>
+        {% endfor %}
+      </ul>
+    </nav>
+
+    <main>
+
+      {% if post %}
+
+        {% post_wrap %}
+
+          <header>
+            {% post_title outer_tag="h1" %}
+            <p>By {% post_author %}. {% post_date %}</p>
+          </header>
+
+          {% post_content outer_tag="section" %}
+
+          <footer>
+            <p>Categories: {% post_categories "span" "badge" %}</p>
+          </footer>
+
+        {% end_post_wrap %}
+
+      {% else %}
+
+        <h1>Latest Posts</h1>
+
+        {% for post in posts %}
+
+          {% post_wrap %}
+
+            <header>
+              {% post_title outer_tag="h2" %}
+              <p>By {% post_author %}. {% post_date %}</p>
+            </header>
+
+            {% post_content outer_tag="section" %}
+
+            <footer>
+              <p>Categories: {% post_categories "span" "badge" %}</p>
+            </footer>
+
+          {% end_post_wrap %}
+
+        {% empty %}
+
+          <p>No posts available.</p>
+
+        {% endfor %}
+
+      {% endif %}
+
+      {% pagination_links %}
+    </main>
+
+    <footer>
+      <p>{% blog_title_link %}</p>
+    </footer>
+  </body>
+</html>
diff --git a/src/djpress/utils.py b/src/djpress/utils.py
index 6ed18eb..b11a135 100644
--- a/src/djpress/utils.py
+++ b/src/djpress/utils.py
@@ -83,15 +83,53 @@ def validate_date_parts(year: str | None, month: str | None, day: str | None) ->
     return result
 
 
-def get_template_name(templates: list[str]) -> str:
+def get_templates(view_name: str) -> list[str]:
+    """Get the template names for a view.
+
+    Args:
+        view_name (str): The view name.
+
+    Returns:
+        list[str]: The list of template names.
+    """
+    theme = djpress_settings.THEME
+
+    template = ""
+
+    if view_name == "index":
+        template = f"djpress/{theme}/home.html"
+
+    if view_name == "archive_posts":
+        template = f"djpress/{theme}/archives.html"
+
+    if view_name == "category_posts":
+        template = f"djpress/{theme}/category.html"
+
+    if view_name == "author_posts":
+        template = f"djpress/{theme}/author.html"
+
+    if view_name == "single_post":
+        template = f"djpress/{theme}/single.html"
+
+    if view_name == "single_page":
+        template = f"djpress/{theme}/page.html"
+
+    default_template = f"djpress/{theme}/index.html"
+
+    return [template, default_template] if template else [default_template]
+
+
+def get_template_name(view_name: str) -> str:
     """Return the first template that exists.
 
     Args:
-        templates (list[str]): The list of template names.
+        view_name (str): The view name
 
     Returns:
         str: The template name.
     """
+    templates = get_templates(view_name)
+
     try:
         template = str(select_template(templates).template.name)
     except TemplateDoesNotExist as exc:
diff --git a/src/djpress/views.py b/src/djpress/views.py
index c06ce25..7697bad 100644
--- a/src/djpress/views.py
+++ b/src/djpress/views.py
@@ -86,12 +86,7 @@ def index(
     Context:
         posts (Page): The published posts as a Page object.
     """
-    template_names: list[str] = [
-        "djpress/home.html",
-        "djpress/index.html",
-    ]
-
-    template: str = get_template_name(templates=template_names)
+    template: str = get_template_name("index")
 
     posts = Paginator(
         Post.post_objects.get_published_posts(),
@@ -129,11 +124,7 @@ def archive_posts(
     Context:
         posts (Page): The published posts for the date as a Page object.
     """
-    template_names: list[str] = [
-        "djpress/archives.html",
-        "djpress/index.html",
-    ]
-    template: str = get_template_name(templates=template_names)
+    template: str = get_template_name(view_name="archive_posts")
 
     try:
         date_parts = validate_date_parts(year=year, month=month, day=day)
@@ -172,11 +163,7 @@ def category_posts(request: HttpRequest, slug: str) -> HttpResponse:
         posts (Page): The published posts for the category as a Page object.
         category (Category): The category object.
     """
-    template_names: list[str] = [
-        "djpress/category.html",
-        "djpress/index.html",
-    ]
-    template: str = get_template_name(templates=template_names)
+    template: str = get_template_name(view_name="category_posts")
 
     try:
         category: Category = Category.objects.get_category_by_slug(slug=slug)
@@ -212,11 +199,7 @@ def author_posts(request: HttpRequest, author: str) -> HttpResponse:
         posts (Page): The published posts by the author as a Page object.
         author (User): The author as a User object.
     """
-    template_names: list[str] = [
-        "djpress/author.html",
-        "djpress/index.html",
-    ]
-    template: str = get_template_name(templates=template_names)
+    template: str = get_template_name(view_name="author_posts")
 
     try:
         user: User = User.objects.get(username=author)
@@ -260,11 +243,6 @@ def single_post(
     Context:
         post (Post): The post object.
     """
-    template_names: list[str] = [
-        "djpress/single.html",
-        "djpress/index.html",
-    ]
-
     try:
         date_parts = validate_date_parts(year=year, month=month, day=day)
         post = Post.post_objects.get_published_post_by_slug(slug=slug, **date_parts)
@@ -275,7 +253,8 @@ def single_post(
         msg = "Post not found"
         raise Http404(msg) from exc
 
-    template: str = get_template_name(templates=template_names)
+    template: str = get_template_name(view_name="single_post")
+
     return render(
         request=request,
         context=context,
@@ -299,11 +278,6 @@ def single_page(request: HttpRequest, path: str) -> HttpResponse:
     Raises:
         Http404: If the page is not found.
     """
-    template_names: list[str] = [
-        "djpress/single.html",
-        "djpress/index.html",
-    ]
-
     try:
         post = Post.page_objects.get_published_page_by_path(path)
         context: dict = {"post": post}
@@ -311,7 +285,7 @@ def single_page(request: HttpRequest, path: str) -> HttpResponse:
         msg = "Page not found"
         raise Http404(msg) from exc
 
-    template: str = get_template_name(templates=template_names)
+    template: str = get_template_name(view_name="single_page")
 
     return render(
         request=request,


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index ad588d0..5334676 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -89,19 +89,25 @@ def test_render_markdown_image_with_title():
     )
 
 
-def test_get_template_name():
+def test_get_template_name(settings):
     # Test case 1 - template exists
     templates = [
         "djpress/not-exists.html",
-        "djpress/index.html",
+        "djpress/default/index.html",
     ]
     template_name = get_template_name(templates)
-    assert template_name == "djpress/index.html"
+    assert template_name == "djpress/default/index.html"
 
-    # Test case 2 - template does not exist
+    # Test case 2 - template does not exist - fall back to default
     templates = [
         "djpress/not-exists.html",
         "djpress/not-exists-2.html",
     ]
+    template_name = get_template_name(templates)
+    assert template_name == "djpress/default/index.html"
+
+    # Test case 3 - default template file does not exist
+    settings.DJPRESS_SETTINGS["THEME"] = "not-exists"
+
     with pytest.raises(TemplateDoesNotExist):
         get_template_name(templates)