aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site
diff options
context:
space:
mode:
authorGravatar Kieran Siek <[email protected]>2021-03-26 14:40:59 +0800
committerGravatar GitHub <[email protected]>2021-03-26 14:40:59 +0800
commit494c63a51a778253e8f90868338d42bd41a700a9 (patch)
treeb48cf80d9b5af914e058d5bd0c616f81972e040b /pydis_site
parentMerge pull request #427 from python-discord/ks123/dewikification/event-pages (diff)
parentRemove guide reference to `markdown2`. (diff)
Merge pull request #393 from ks129/guides-app
Dewikification - Create content app
Diffstat (limited to 'pydis_site')
-rw-r--r--pydis_site/apps/content/__init__.py0
-rw-r--r--pydis_site/apps/content/apps.py7
-rw-r--r--pydis_site/apps/content/migrations/__init__.py0
-rw-r--r--pydis_site/apps/content/resources/_info.yml2
-rw-r--r--pydis_site/apps/content/resources/guides/_info.yml2
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/_info.yml2
-rw-r--r--pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md143
-rw-r--r--pydis_site/apps/content/tests/__init__.py0
-rw-r--r--pydis_site/apps/content/tests/helpers.py84
-rw-r--r--pydis_site/apps/content/tests/test_utils.py91
-rw-r--r--pydis_site/apps/content/tests/test_views.py145
-rw-r--r--pydis_site/apps/content/urls.py9
-rw-r--r--pydis_site/apps/content/utils.py57
-rw-r--r--pydis_site/apps/content/views/__init__.py3
-rw-r--r--pydis_site/apps/content/views/page_category.py61
-rw-r--r--pydis_site/apps/home/urls.py1
-rw-r--r--pydis_site/settings.py8
-rw-r--r--pydis_site/static/css/content/page.css31
-rw-r--r--pydis_site/templates/content/base.html36
-rw-r--r--pydis_site/templates/content/listing.html27
-rw-r--r--pydis_site/templates/content/page.html31
-rw-r--r--pydis_site/templates/resources/resources.html2
22 files changed, 741 insertions, 1 deletions
diff --git a/pydis_site/apps/content/__init__.py b/pydis_site/apps/content/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/pydis_site/apps/content/__init__.py
diff --git a/pydis_site/apps/content/apps.py b/pydis_site/apps/content/apps.py
new file mode 100644
index 00000000..1e300a48
--- /dev/null
+++ b/pydis_site/apps/content/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class ContentConfig(AppConfig):
+ """Django AppConfig for content app."""
+
+ name = 'content'
diff --git a/pydis_site/apps/content/migrations/__init__.py b/pydis_site/apps/content/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/pydis_site/apps/content/migrations/__init__.py
diff --git a/pydis_site/apps/content/resources/_info.yml b/pydis_site/apps/content/resources/_info.yml
new file mode 100644
index 00000000..583cab18
--- /dev/null
+++ b/pydis_site/apps/content/resources/_info.yml
@@ -0,0 +1,2 @@
+name: Pages
+description: Guides, articles, and pages hosted on the site.
diff --git a/pydis_site/apps/content/resources/guides/_info.yml b/pydis_site/apps/content/resources/guides/_info.yml
new file mode 100644
index 00000000..59c60a7b
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/_info.yml
@@ -0,0 +1,2 @@
+name: Guides
+description: Made by us, for you.
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/_info.yml b/pydis_site/apps/content/resources/guides/pydis-guides/_info.yml
new file mode 100644
index 00000000..7c9a2225
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/_info.yml
@@ -0,0 +1,2 @@
+name: Python Discord Guides
+description: Guides related to the Python Discord server and community.
diff --git a/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md
new file mode 100644
index 00000000..f258ef74
--- /dev/null
+++ b/pydis_site/apps/content/resources/guides/pydis-guides/how-to-contribute-a-page.md
@@ -0,0 +1,143 @@
+---
+title: How to Contribute a Page
+description: Learn how to write and publish a page to this website.
+icon_class: fas
+icon: fa-info
+relevant_links:
+ Contributing to Site: https://pythondiscord.com/pages/contributing/site/
+ Using Git: https://pythondiscord.com/pages/contributing/working-with-git/
+---
+
+Pages, which include guides, articles, and other static content, are stored in markdown files in the `site` repository on Github.
+If you are interested in writing or modifying pages seen here on the site, follow the steps below.
+
+For further assistance and help with contributing pages, send a message to the `#dev-contrib` channel in the Discord server!
+
+## Prerequisites
+Before working on a new page, you have to [setup the site project locally](https://pythondiscord.com/pages/contributing/site/).
+It is also a good idea to familiarize yourself with the [git workflow](https://pythondiscord.com/pages/contributing/working-with-git/), as it is part of the contribution workflow.
+
+Additionally, please submit your proposed page or modification to a page as an [issue in the site repository](https://github.com/python-discord/site/issues), or discuss it in the `#dev-contrib` channel in the server.
+As website changes require staff approval, discussing the page content beforehand helps with accelerating the contribution process, and avoids wasted work in the event the proposed page is not accepted.
+
+## Creating the Page
+All pages are located in the `site` repo, at the path `pydis_site/apps/content/resources/`. This is the root folder, which corresponds to the URL `www.pythondiscord.com/pages/`.
+For example, the file `pydis_site/apps/content/resources/hello-world.md` will result in a page available at `www.pythondiscord.com/pages/hello-world`.
+
+Nested folders represent page categories on the website. Each folder under the root folder must include a `_info.yml` file with the following:
+
+```yml
+name: Category name
+description: Category description
+```
+
+All the markdown files in this folder will then be under this category.
+
+## Writing the Page
+Files representing pages are in `.md` (Markdown) format, with all-lowercase filenames and spaces replaced with `-` characters.
+
+Each page must include required metadata, and optionally additional metadata to modify the appearance of the page.
+The metadata is written in YAML, and should be enclosed in triple dashes `---` *at the top of the markdown file*.
+
+**Example:**
+```yaml
+---
+title: How to Contribute a Page
+description: Learn how to write and publish a page to this website.
+icon_class: fas
+icon: fa-info
+relevant_links:
+ Contributing to Site: https://pythondiscord.com/pages/contributing/site/
+ Using Git: https://pythondiscord.com/pages/contributing/working-with-git/
+---
+
+Pages, which include guides, articles, and other static content,...
+```
+
+### Required Fields
+- **title:** Easily-readable title for your article.
+- **description:** Short, 1-2 line description of the page's content.
+
+### Optional Fields
+- **icon_class:** Favicon class for the category entry for the page. Default: `fab`
+- **icon:** Favicon for the category entry for the page. Default: `fa-python` <i class="fab fa-python is-black" aria-hidden="true"></i>
+- **relevant_links:** A YAML dictionary containing `text:link` pairs. See the example above.
+
+## Extended Markdown
+
+Apart from standard Markdown, certain additions are available:
+
+### Abbreviations
+HTML `<abbr>` tags can be used in markdown using this format:
+
+**Markdown:**
+```nohighlight
+This website is HTML generated from YAML and Markdown.
+
+*[HTML]: Hyper Text Markup Language
+*[YAML]: YAML Ain't Markup Language
+```
+
+**Output:**
+
+This website is <abbr title="Hyper Text Markup Language">HTML</abbr>
+generated from <abbr title="YAML Ain't Markup Language">YAML</abbr> and Markdown.
+
+---
+
+### Footnotes
+**Markdown:**
+```nohighlight
+This footnote[^1] links to the bottom[^custom_label] of the page[^3].
+
+[^1]: Footnote labels start with a caret `^`.
+[^3]: The footnote link is numbered based on the order of the labels.
+[^custom label]: Footnote labels can contain any text within square brackets.
+```
+
+**Output:**
+
+This footnote[^1] links to the bottom[^custom label] of the page[^3].
+
+[^1]: Footnote labels start with a caret `^`.
+[^3]: The footnote link is numbered based on the order of the labels.
+[^custom label]: Footnote labels can contain any text within square brackets.
+
+---
+
+### Tables
+
+**Markdown:**
+```nohighlight
+| This is header | This is another header |
+| -------------- | ---------------------- |
+| An item | Another item |
+```
+
+**Output:**
+
+| This is header | This is another header |
+| -------------- | ---------------------- |
+| An item | Another item |
+
+---
+
+### Codeblock Syntax Highlighting
+Syntax highlighting is provided by `highlight.js`.
+To activate syntax highlighting, put the language directly after the starting backticks.
+
+**Markdown:**
+````nohighlight
+```python
+import os
+
+path = os.path.join("foo", "bar")
+```
+````
+
+**Output:**
+```python
+import os
+
+path = os.path.join("foo", "bar")
+```
diff --git a/pydis_site/apps/content/tests/__init__.py b/pydis_site/apps/content/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/pydis_site/apps/content/tests/__init__.py
diff --git a/pydis_site/apps/content/tests/helpers.py b/pydis_site/apps/content/tests/helpers.py
new file mode 100644
index 00000000..4e0cca34
--- /dev/null
+++ b/pydis_site/apps/content/tests/helpers.py
@@ -0,0 +1,84 @@
+from pyfakefs.fake_filesystem_unittest import TestCase
+
+# Valid markdown content with YAML metadata
+MARKDOWN_WITH_METADATA = """
+---
+title: TestTitle
+description: TestDescription
+relevant_links:
+ Python Discord: https://pythondiscord.com
+ Discord: https://discord.com
+---
+# This is a header.
+"""
+
+MARKDOWN_WITHOUT_METADATA = """#This is a header."""
+
+# Valid YAML in a _info.yml file
+CATEGORY_INFO = """
+name: Category Name
+description: Description
+"""
+
+# The HTML generated from the above markdown data
+PARSED_HTML = (
+ '<h1 id="this-is-a-header">This is a header.'
+ '<a class="headerlink" href="#this-is-a-header" title="Permanent link">&para;</a></h1>'
+)
+
+# The YAML metadata parsed from the above markdown data
+PARSED_METADATA = {
+ "title": "TestTitle", "description": "TestDescription",
+ "relevant_links": {
+ "Python Discord": "https://pythondiscord.com",
+ "Discord": "https://discord.com"
+ }
+}
+
+# The YAML data parsed from the above _info.yml file
+PARSED_CATEGORY_INFO = {"name": "Category Name", "description": "Description"}
+
+
+class MockPagesTestCase(TestCase):
+ """
+ TestCase with a fake filesystem for testing.
+
+ Structure:
+ ├── _info.yml
+ ├── root.md
+ ├── root_without_metadata.md
+ ├── not_a_page.md
+ ├── tmp
+ |   ├── _info.yml
+ |   └── category_without_info
+ └── category
+    ├── _info.yml
+    ├── with_metadata.md
+    └── subcategory
+    ├── with_metadata.md
+       └── without_metadata.md
+ """
+
+ def setUp(self):
+ """Create the fake filesystem."""
+ self.setUpPyfakefs()
+
+ self.fs.create_file("_info.yml", contents=CATEGORY_INFO)
+ self.fs.create_file("root.md", contents=MARKDOWN_WITH_METADATA)
+ self.fs.create_file("root_without_metadata.md", contents=MARKDOWN_WITHOUT_METADATA)
+ self.fs.create_file("not_a_page.md/_info.yml", contents=CATEGORY_INFO)
+ self.fs.create_file("category/_info.yml", contents=CATEGORY_INFO)
+ self.fs.create_file("category/with_metadata.md", contents=MARKDOWN_WITH_METADATA)
+ self.fs.create_file("category/subcategory/_info.yml", contents=CATEGORY_INFO)
+ self.fs.create_file(
+ "category/subcategory/with_metadata.md", contents=MARKDOWN_WITH_METADATA
+ )
+ self.fs.create_file(
+ "category/subcategory/without_metadata.md", contents=MARKDOWN_WITHOUT_METADATA
+ )
+
+ # There is always a `tmp` directory in the filesystem, so make it a category
+ # for testing purposes.
+ # See: https://jmcgeheeiv.github.io/pyfakefs/release/usage.html#os-temporary-directories
+ self.fs.create_file("tmp/_info.yml", contents=CATEGORY_INFO)
+ self.fs.create_dir("tmp/category_without_info")
diff --git a/pydis_site/apps/content/tests/test_utils.py b/pydis_site/apps/content/tests/test_utils.py
new file mode 100644
index 00000000..58175d6f
--- /dev/null
+++ b/pydis_site/apps/content/tests/test_utils.py
@@ -0,0 +1,91 @@
+from pathlib import Path
+
+from django.http import Http404
+
+from pydis_site.apps.content import utils
+from pydis_site.apps.content.tests.helpers import (
+ MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA
+)
+
+
+class GetCategoryTests(MockPagesTestCase):
+ """Tests for the get_category function."""
+
+ def test_get_valid_category(self):
+ result = utils.get_category(Path("category"))
+
+ self.assertEqual(result, {"name": "Category Name", "description": "Description"})
+
+ def test_get_nonexistent_category(self):
+ with self.assertRaises(Http404):
+ utils.get_category(Path("invalid"))
+
+ def test_get_category_with_path_to_file(self):
+ # Valid categories are directories, not files
+ with self.assertRaises(Http404):
+ utils.get_category(Path("root.md"))
+
+ def test_get_category_without_info_yml(self):
+ # Categories should provide an _info.yml file
+ with self.assertRaises(FileNotFoundError):
+ utils.get_category(Path("tmp/category_without_info"))
+
+
+class GetCategoriesTests(MockPagesTestCase):
+ """Tests for the get_categories function."""
+
+ def test_get_root_categories(self):
+ result = utils.get_categories(Path("."))
+
+ info = PARSED_CATEGORY_INFO
+ self.assertEqual(result, {"category": info, "tmp": info, "not_a_page.md": info})
+
+ def test_get_categories_with_subcategories(self):
+ result = utils.get_categories(Path("category"))
+
+ self.assertEqual(result, {"subcategory": PARSED_CATEGORY_INFO})
+
+ def test_get_categories_without_subcategories(self):
+ result = utils.get_categories(Path("category/subcategory"))
+
+ self.assertEqual(result, {})
+
+
+class GetCategoryPagesTests(MockPagesTestCase):
+ """Tests for the get_category_pages function."""
+
+ def test_get_pages_in_root_category_successfully(self):
+ """The method should successfully retrieve page metadata."""
+ root_category_pages = utils.get_category_pages(Path("."))
+ self.assertEqual(
+ root_category_pages, {"root": PARSED_METADATA, "root_without_metadata": {}}
+ )
+
+ def test_get_pages_in_subcategories_successfully(self):
+ """The method should successfully retrieve page metadata."""
+ category_pages = utils.get_category_pages(Path("category"))
+
+ # Page metadata is properly retrieved
+ self.assertEqual(category_pages, {"with_metadata": PARSED_METADATA})
+
+
+class GetPageTests(MockPagesTestCase):
+ """Tests for the get_page function."""
+
+ def test_get_page(self):
+ cases = [
+ ("Root page with metadata", "root.md", PARSED_HTML, PARSED_METADATA),
+ ("Root page without metadata", "root_without_metadata.md", PARSED_HTML, {}),
+ ("Page with metadata", "category/with_metadata.md", PARSED_HTML, PARSED_METADATA),
+ ("Page without metadata", "category/subcategory/without_metadata.md", PARSED_HTML, {}),
+ ]
+
+ for msg, page_path, expected_html, expected_metadata in cases:
+ with self.subTest(msg=msg):
+ html, metadata = utils.get_page(Path(page_path))
+ self.assertEqual(html, expected_html)
+ self.assertEqual(metadata, expected_metadata)
+
+ def test_get_nonexistent_page_returns_404(self):
+ with self.assertRaises(Http404):
+ utils.get_page(Path("invalid"))
diff --git a/pydis_site/apps/content/tests/test_views.py b/pydis_site/apps/content/tests/test_views.py
new file mode 100644
index 00000000..560378bc
--- /dev/null
+++ b/pydis_site/apps/content/tests/test_views.py
@@ -0,0 +1,145 @@
+from pathlib import Path
+from unittest import TestCase
+
+from django.http import Http404
+from django.test import RequestFactory, SimpleTestCase, override_settings
+from pyfakefs import fake_filesystem_unittest
+
+from pydis_site.apps.content.tests.helpers import (
+ MockPagesTestCase, PARSED_CATEGORY_INFO, PARSED_HTML, PARSED_METADATA
+)
+from pydis_site.apps.content.views import PageOrCategoryView
+
+
+# Set the module constant within Patcher to use the fake filesystem
+# https://jmcgeheeiv.github.io/pyfakefs/master/usage.html#modules-to-reload
+with fake_filesystem_unittest.Patcher() as _:
+ BASE_PATH = Path(".")
+
+
+@override_settings(PAGES_PATH=BASE_PATH)
+class PageOrCategoryViewTests(MockPagesTestCase, SimpleTestCase, TestCase):
+ """Tests for the PageOrCategoryView class."""
+
+ def setUp(self):
+ """Set test helpers, then set up fake filesystem."""
+ self.factory = RequestFactory()
+ self.view = PageOrCategoryView.as_view()
+ self.ViewClass = PageOrCategoryView()
+ super().setUp()
+
+ # Integration tests
+ def test_valid_page_or_category_returns_200(self):
+ cases = [
+ ("Page at root", "root"),
+ ("Category page", "category"),
+ ("Page in category", "category/with_metadata"),
+ ("Subcategory page", "category/subcategory"),
+ ("Page in subcategory", "category/subcategory/with_metadata"),
+ ]
+ for msg, path in cases:
+ with self.subTest(msg=msg, path=path):
+ request = self.factory.get(f"/{path}")
+ response = self.view(request, location=path)
+ self.assertEqual(response.status_code, 200)
+
+ def test_nonexistent_page_returns_404(self):
+ with self.assertRaises(Http404):
+ request = self.factory.get("/invalid")
+ self.view(request, location="invalid")
+
+ # Unit tests
+ def test_get_template_names_returns_correct_templates(self):
+ category_template = "content/listing.html"
+ page_template = "content/page.html"
+ cases = [
+ ("root", page_template),
+ ("root_without_metadata", page_template),
+ ("category/with_metadata", page_template),
+ ("category/subcategory/with_metadata", page_template),
+ ("category", category_template),
+ ("category/subcategory", category_template),
+ ]
+
+ for path, expected_template in cases:
+ with self.subTest(path=path, expected_template=expected_template):
+ self.ViewClass.full_location = Path(path)
+ self.assertEqual(self.ViewClass.get_template_names(), [expected_template])
+
+ def test_get_template_names_with_nonexistent_paths_returns_404(self):
+ for path in ("invalid", "another_invalid", "nonexistent"):
+ with self.subTest(path=path):
+ self.ViewClass.full_location = Path(path)
+ with self.assertRaises(Http404):
+ self.ViewClass.get_template_names()
+
+ def test_get_context_data_with_valid_page(self):
+ """The method should return required fields in the template context."""
+ request = self.factory.get("/root")
+ self.ViewClass.setup(request)
+ self.ViewClass.dispatch(request, location="root")
+
+ cases = [
+ ("Context includes HTML page content", "page", PARSED_HTML),
+ ("Context includes page title", "page_title", PARSED_METADATA["title"]),
+ (
+ "Context includes page description",
+ "page_description",
+ PARSED_METADATA["description"]
+ ),
+ (
+ "Context includes relevant link names and URLs",
+ "relevant_links",
+ PARSED_METADATA["relevant_links"]
+ ),
+ ]
+ context = self.ViewClass.get_context_data()
+ for msg, key, expected_value in cases:
+ with self.subTest(msg=msg):
+ self.assertEqual(context[key], expected_value)
+
+ def test_get_context_data_with_valid_category(self):
+ """The method should return required fields in the template context."""
+ request = self.factory.get("/category")
+ self.ViewClass.setup(request)
+ self.ViewClass.dispatch(request, location="category")
+
+ cases = [
+ (
+ "Context includes subcategory names and their information",
+ "categories",
+ {"subcategory": PARSED_CATEGORY_INFO}
+ ),
+ (
+ "Context includes page names and their metadata",
+ "pages",
+ {"with_metadata": PARSED_METADATA}
+ ),
+ (
+ "Context includes page description",
+ "page_description",
+ PARSED_CATEGORY_INFO["description"]
+ ),
+ ("Context includes page title", "page_title", PARSED_CATEGORY_INFO["name"]),
+ ]
+
+ context = self.ViewClass.get_context_data()
+ for msg, key, expected_value in cases:
+ with self.subTest(msg=msg):
+ self.assertEqual(context[key], expected_value)
+
+ def test_get_context_data_breadcrumbs(self):
+ """The method should return correct breadcrumbs."""
+ request = self.factory.get("/category/subcategory/with_metadata")
+ self.ViewClass.setup(request)
+ self.ViewClass.dispatch(request, location="category/subcategory/with_metadata")
+
+ context = self.ViewClass.get_context_data()
+ self.assertEquals(
+ context["breadcrumb_items"],
+ [
+ {"name": PARSED_CATEGORY_INFO["name"], "path": "."},
+ {"name": PARSED_CATEGORY_INFO["name"], "path": "category"},
+ {"name": PARSED_CATEGORY_INFO["name"], "path": "category/subcategory"},
+ ]
+ )
diff --git a/pydis_site/apps/content/urls.py b/pydis_site/apps/content/urls.py
new file mode 100644
index 00000000..c11b222a
--- /dev/null
+++ b/pydis_site/apps/content/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+
+from . import views
+
+app_name = "content"
+urlpatterns = [
+ path("", views.PageOrCategoryView.as_view(), name='pages'),
+ path("<path:location>/", views.PageOrCategoryView.as_view(), name='page_category'),
+]
diff --git a/pydis_site/apps/content/utils.py b/pydis_site/apps/content/utils.py
new file mode 100644
index 00000000..726c991f
--- /dev/null
+++ b/pydis_site/apps/content/utils.py
@@ -0,0 +1,57 @@
+from pathlib import Path
+from typing import Dict, Tuple
+
+import frontmatter
+import markdown
+import yaml
+from django.http import Http404
+from markdown.extensions.toc import TocExtension
+
+
+def get_category(path: Path) -> Dict[str, str]:
+ """Load category information by name from _info.yml."""
+ if not path.is_dir():
+ raise Http404("Category not found.")
+
+ return yaml.safe_load(path.joinpath("_info.yml").read_text(encoding="utf-8"))
+
+
+def get_categories(path: Path) -> Dict[str, Dict]:
+ """Get information for all categories."""
+ categories = {}
+
+ for item in path.iterdir():
+ if item.is_dir():
+ categories[item.name] = get_category(item)
+
+ return categories
+
+
+def get_category_pages(path: Path) -> Dict[str, Dict]:
+ """Get all page names and their metadata at a category path."""
+ pages = {}
+
+ for item in path.glob("*.md"):
+ if item.is_file():
+ pages[item.stem] = frontmatter.load(item).metadata
+
+ return pages
+
+
+def get_page(path: Path) -> Tuple[str, Dict]:
+ """Get one specific page."""
+ if not path.is_file():
+ raise Http404("Page not found.")
+
+ metadata, content = frontmatter.parse(path.read_text(encoding="utf-8"))
+ html = markdown.markdown(
+ content,
+ extensions=[
+ "extra",
+ # Empty string for marker to disable text searching for [TOC]
+ # By using a metadata key instead, we save time on long markdown documents
+ TocExtension(title="Table of Contents:", permalink=True, marker="")
+ ]
+ )
+
+ return str(html), metadata
diff --git a/pydis_site/apps/content/views/__init__.py b/pydis_site/apps/content/views/__init__.py
new file mode 100644
index 00000000..70ea1c7a
--- /dev/null
+++ b/pydis_site/apps/content/views/__init__.py
@@ -0,0 +1,3 @@
+from .page_category import PageOrCategoryView
+
+__all__ = ["PageOrCategoryView"]
diff --git a/pydis_site/apps/content/views/page_category.py b/pydis_site/apps/content/views/page_category.py
new file mode 100644
index 00000000..eec4e7e5
--- /dev/null
+++ b/pydis_site/apps/content/views/page_category.py
@@ -0,0 +1,61 @@
+import typing as t
+from pathlib import Path
+
+from django.conf import settings
+from django.http import Http404
+from django.views.generic import TemplateView
+
+from pydis_site.apps.content import utils
+
+
+class PageOrCategoryView(TemplateView):
+ """Handles pages and page categories."""
+
+ def dispatch(self, request: t.Any, *args, **kwargs) -> t.Any:
+ """Conform URL path location to the filesystem path."""
+ self.location = Path(kwargs.get("location", ""))
+ self.full_location = settings.PAGES_PATH / self.location
+
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_template_names(self) -> t.List[str]:
+ """Checks if the view uses the page template or listing template."""
+ if self.full_location.is_dir():
+ template_name = "content/listing.html"
+ elif self.full_location.with_suffix(".md").is_file():
+ template_name = "content/page.html"
+ else:
+ raise Http404
+
+ return [template_name]
+
+ def get_context_data(self, **kwargs) -> t.Dict[str, t.Any]:
+ """Assign proper context variables based on what resource user requests."""
+ context = super().get_context_data(**kwargs)
+
+ if self.full_location.is_dir():
+ context["categories"] = utils.get_categories(self.full_location)
+ context["pages"] = utils.get_category_pages(self.full_location)
+
+ category = utils.get_category(self.full_location)
+ context["page_title"] = category["name"]
+ context["page_description"] = category["description"]
+
+ context["path"] = f"{self.location}/" # Add trailing slash here to simplify template
+ elif self.full_location.with_suffix(".md").is_file():
+ page, metadata = utils.get_page(self.full_location.with_suffix(".md"))
+ context["page"] = page
+ context["page_title"] = metadata["title"]
+ context["page_description"] = metadata["description"]
+ context["relevant_links"] = metadata.get("relevant_links", {})
+ else:
+ raise Http404
+
+ context["breadcrumb_items"] = [
+ {
+ "name": utils.get_category(settings.PAGES_PATH / location)["name"],
+ "path": str(location)
+ } for location in reversed(self.location.parents)
+ ]
+
+ return context
diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py
index d7db6ff1..3c716875 100644
--- a/pydis_site/apps/home/urls.py
+++ b/pydis_site/apps/home/urls.py
@@ -8,5 +8,6 @@ urlpatterns = [
path('', HomeView.as_view(), name='home'),
path('admin/', admin.site.urls),
path('resources/', include('pydis_site.apps.resources.urls')),
+ path('pages/', include('pydis_site.apps.content.urls')),
path('events/', include('pydis_site.apps.events.urls', namespace='events')),
]
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index 67afdbcb..3abf556a 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -85,6 +85,7 @@ INSTALLED_APPS = [
'pydis_site.apps.home',
'pydis_site.apps.staff',
'pydis_site.apps.resources',
+ 'pydis_site.apps.content',
'pydis_site.apps.events',
'django.contrib.admin',
@@ -280,3 +281,10 @@ BULMA_SETTINGS = {
"footer-padding": "1rem 1.5rem 1rem",
}
}
+
+# Information about site repository
+SITE_REPOSITORY_OWNER = "python-discord"
+SITE_REPOSITORY_NAME = "site"
+SITE_REPOSITORY_BRANCH = "master"
+
+PAGES_PATH = Path(BASE_DIR, "pydis_site", "apps", "content", "resources")
diff --git a/pydis_site/static/css/content/page.css b/pydis_site/static/css/content/page.css
new file mode 100644
index 00000000..57d7472b
--- /dev/null
+++ b/pydis_site/static/css/content/page.css
@@ -0,0 +1,31 @@
+.breadcrumb-section {
+ padding: 1rem;
+}
+
+i.has-icon-padding {
+ padding: 0 10px 25px 0;
+}
+
+/*
+ * Move padding padding from <pre> tag to hljs <code> tags so the padding
+ * space is colored the same as the background of hljs <code> blocks.
+ */
+.content pre {
+ padding: 0;
+}
+
+code.hljs {
+ padding: 1.75em 2em;
+}
+
+/*
+ * Show header permalink on hover.
+ */
+.headerlink {
+ display: none;
+ padding-left: 0.5em;
+}
+
+:is(h1, h2, h3, h4, h5, h6):hover > .headerlink {
+ display: inline;
+}
diff --git a/pydis_site/templates/content/base.html b/pydis_site/templates/content/base.html
new file mode 100644
index 00000000..19eec5d4
--- /dev/null
+++ b/pydis_site/templates/content/base.html
@@ -0,0 +1,36 @@
+{% extends 'base/base.html' %}
+{% load static %}
+
+{% block title %}{{ page_title }}{% endblock %}
+{% block head %}
+ <meta property="og:title" content="Python Discord - {{ page_title }}" />
+ <meta property="og:type" content="website" />
+ <meta property="og:description" content="{{ page_description }}" />
+ <link rel="stylesheet" href="{% static "css/content/page.css" %}">
+{% endblock %}
+
+{% block content %}
+ {% include "base/navbar.html" %}
+
+ <section class="breadcrumb-section section">
+ <div class="container">
+ <nav class="breadcrumb is-pulled-left" aria-label="breadcrumbs">
+ <ul>
+ {% for item in breadcrumb_items %}
+ <li><a href="{% url "content:page_category" location=item.path %}">{{ item.name }}</a></li>
+ {% endfor %}
+ <li class="is-active"><a href="#">{{ page_title }}</a></li>
+ </ul>
+ </nav>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="container">
+ <div class="content">
+ <h1 class="title">{{ page_title }}</h1>
+ {% block page_content %}{% endblock %}
+ </div>
+ </div>
+ </section>
+{% endblock %}
diff --git a/pydis_site/templates/content/listing.html b/pydis_site/templates/content/listing.html
new file mode 100644
index 00000000..6de306b0
--- /dev/null
+++ b/pydis_site/templates/content/listing.html
@@ -0,0 +1,27 @@
+{% extends 'content/base.html' %}
+
+{% block page_content %}
+ {% for category, data in categories.items %}
+ <div class="box" style="max-width: 800px;">
+ <span class="icon is-size-4 is-medium">
+ <i class="fas fa-folder is-size-3 is-black has-icon-padding" aria-hidden="true"></i>
+ </span>
+
+ <a href="{% url "content:page_category" location=path|add:category %}">
+ <span class="is-size-4 has-text-weight-bold">{{ data.name }}</span>
+ </a>
+ <p class="is-italic">{{ data.description }}</p>
+ </div>
+ {% endfor %}
+ {% for page, data in pages.items %}
+ <div class="box" style="max-width: 800px;">
+ <span class="icon is-size-4 is-medium">
+ <i class="{{ data.icon_class|default:"fab" }} {{ data.icon|default:"fa-python" }} is-size-3 is-black has-icon-padding" aria-hidden="true"></i>
+ </span>
+ <a href="{% url "content:page_category" location=path|add:page %}">
+ <span class="is-size-4 has-text-weight-bold">{{ data.title }}</span>
+ </a>
+ <p class="is-italic">{{ data.description }}</p>
+ </div>
+ {% endfor %}
+{% endblock %}
diff --git a/pydis_site/templates/content/page.html b/pydis_site/templates/content/page.html
new file mode 100644
index 00000000..06d74208
--- /dev/null
+++ b/pydis_site/templates/content/page.html
@@ -0,0 +1,31 @@
+{% extends 'content/base.html' %}
+
+{% block head %}
+ {{ block.super }}
+ <link rel="stylesheet"
+ href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/styles/atom-one-dark-reasonable.min.css">
+ <script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.7.1/highlight.min.js"></script>
+ <script>hljs.initHighlightingOnLoad();</script>
+{% endblock %}
+
+{% block page_content %}
+ {% if relevant_links|length > 0 %}
+ <div class="columns is-variable is-8">
+ <div class="column is-two-thirds">
+ {{ page|safe }}
+ </div>
+ <div class="column">
+ <div class="box">
+ <p class="menu-label">Relevant links</p>
+ <ul class="menu-list">
+ {% for value, link in relevant_links.items %}
+ <li><a class="has-text-link" href="{{link}}">{{ value }}</a></li>
+ {% endfor %}
+ </ul>
+ </div>
+ </div>
+ </div>
+ {% else %}
+ <div>{{ page|safe }}</div>
+ {% endif %}
+{% endblock %}
diff --git a/pydis_site/templates/resources/resources.html b/pydis_site/templates/resources/resources.html
index 6eb32c97..491bc55e 100644
--- a/pydis_site/templates/resources/resources.html
+++ b/pydis_site/templates/resources/resources.html
@@ -15,7 +15,7 @@
<h1>Resources</h1>
<div class="tile is-ancestor">
- <a class="tile is-parent" href="/articles/category/guides">
+ <a class="tile is-parent" href="{% url "content:page_category" location="guides" %}">
<article class="tile is-child box hero is-primary is-bold">
<p class="title is-size-1"><i class="fad fa-info-circle" aria-hidden="true"></i> Guides</p>
<p class="subtitle is-size-4">Made by us, for you</p>