diff --git a/src/psc/__main__.py b/src/psc/__main__.py index cb3a100..162c6f5 100644 --- a/src/psc/__main__.py +++ b/src/psc/__main__.py @@ -102,15 +102,15 @@ def build() -> None: # pragma: no cover # Now for each page resources = get_resources() for page in resources.pages.values(): - response = test_client.get(f"/pages/{page.path.stem}.html") - output = public / f"pages/{page.path.stem}.html" + response = test_client.get(f"/pages/{page.name}.html") + output = public / f"pages/{page.name}.html" output.write_text(response.text) # And for each example for example in resources.examples.values(): - url = f"/gallery/examples/{example.path.stem}/index.html" + url = f"/gallery/examples/{example.name}/index.html" response = test_client.get(url) - output = public / f"gallery/examples/{example.path.stem}/index.html" + output = public / f"gallery/examples/{example.name}/index.html" output.write_text(response.text) diff --git a/src/psc/app.py b/src/psc/app.py index d86e90d..7b5da85 100644 --- a/src/psc/app.py +++ b/src/psc/app.py @@ -1,7 +1,6 @@ """Provide a web server to browse the examples.""" import contextlib from collections.abc import Iterator -from pathlib import PurePath from typing import AsyncContextManager from starlette.applications import Starlette @@ -47,8 +46,10 @@ async def homepage(request: Request) -> _TemplateResponse: async def gallery(request: Request) -> _TemplateResponse: """Handle the gallery listing page.""" - these_examples: Iterator[Example] = request.app.state.resources.examples.values() + resources = request.app.state.resources + these_examples: Iterator[Example] = resources.examples.values() root_path = ".." + these_authors = resources.authors return templates.TemplateResponse( "gallery.jinja2", @@ -57,16 +58,56 @@ async def gallery(request: Request) -> _TemplateResponse: examples=these_examples, root_path=root_path, request=request, + authors=these_authors, + ), + ) + + +async def authors(request: Request) -> _TemplateResponse: + """Handle the author listing page.""" + these_authors: Iterator[Example] = request.app.state.resources.authors.values() + root_path = ".." + + return templates.TemplateResponse( + "authors.jinja2", + dict( + title="Authors", + authors=these_authors, + root_path=root_path, + request=request, + ), + ) + + +async def author(request: Request) -> _TemplateResponse: + """Handle an author page.""" + author_name = request.path_params["author_name"] + resources: Resources = request.app.state.resources + this_author = resources.authors[author_name] + root_path = "../../.." + + return templates.TemplateResponse( + "example.jinja2", + dict( + title=this_author.title, + body=this_author.body, + request=request, + root_path=root_path, ), ) async def example(request: Request) -> _TemplateResponse: """Handle an example page.""" - example_path = PurePath(request.path_params["example_name"]) + example_name = request.path_params["example_name"] resources: Resources = request.app.state.resources - this_example = resources.examples[example_path] + this_example = resources.examples[example_name] root_path = "../../.." + author_name = this_example.author + if author_name: + this_author = resources.authors.get(author_name, None) + else: + this_author = None # Set the pyscript URL to the CDN if we are being built from # the ``psc build`` command. @@ -86,15 +127,16 @@ async def example(request: Request) -> _TemplateResponse: request=request, root_path=root_path, pyscript_url=pyscript_url, + author=this_author, ), ) async def content_page(request: Request) -> _TemplateResponse: """Handle a content page.""" - page_path = PurePath(request.path_params["page_name"]) + page_name = request.path_params["page_name"] resources: Resources = request.app.state.resources - this_page = resources.pages[page_path] + this_page = resources.pages[page_name] return templates.TemplateResponse( "page.jinja2", @@ -113,6 +155,9 @@ async def content_page(request: Request) -> _TemplateResponse: Route("/favicon.png", favicon), Route("/gallery/index.html", gallery), Route("/gallery", gallery), + Route("/authors/index.html", authors), + Route("/authors", authors), + Route("/authors/{author_name}.html", author), Route("/gallery/examples/{example_name}/index.html", example), Route("/gallery/examples/{example_name}/", example), Route("/pages/{page_name}.html", content_page), diff --git a/src/psc/gallery/authors/meg-1.md b/src/psc/gallery/authors/meg-1.md new file mode 100644 index 0000000..004f312 --- /dev/null +++ b/src/psc/gallery/authors/meg-1.md @@ -0,0 +1,6 @@ +--- +title: Margaret +--- + +An I.T. student. +Currently focusing on programming with Python and learning Django development. diff --git a/src/psc/gallery/authors/pauleveritt.md b/src/psc/gallery/authors/pauleveritt.md new file mode 100644 index 0000000..63d665c --- /dev/null +++ b/src/psc/gallery/authors/pauleveritt.md @@ -0,0 +1,6 @@ +--- +title: Paul Everitt +--- + +Python and Web Developer Advocate at @JetBrains for @PyCharm and @WebStormIDE. +Python oldster, Zope/Plone/Pyramid mafia. Girls lacrosse, formerly running. diff --git a/src/psc/gallery/examples/interest_calculator/index.md b/src/psc/gallery/examples/interest_calculator/index.md index 35b0a54..bffb179 100644 --- a/src/psc/gallery/examples/interest_calculator/index.md +++ b/src/psc/gallery/examples/interest_calculator/index.md @@ -1,5 +1,6 @@ --- title: Compound Interest Calculator -subtitle: The classic hello world, but in Python -- in a browser! +subtitle: Enter some numbers, get some numbers. +author: meg-1 --- The *body* description. diff --git a/src/psc/resources.py b/src/psc/resources.py index 808b4bf..5717f8a 100644 --- a/src/psc/resources.py +++ b/src/psc/resources.py @@ -75,7 +75,7 @@ def get_body_content(s: BeautifulSoup, test_path: Path = PYODIDE) -> str: class Resource: """Base dataclass used for all resources.""" - path: PurePath + name: str title: str = "" body: str = "" extra_head: str = "" @@ -91,41 +91,55 @@ class Example(Resource): Meaning, HERE / "examples" / name / "index.html". """ - description: str = "" subtitle: str = "" + description: str = "" + author: str | None = None def __post_init__(self) -> None: """Extract most of the data from the HTML file.""" - # Title, subtitle, description come from the example's MD file. - index_md_file = HERE / "gallery/examples" / self.path / "index.md" + # Title, subtitle, body come from the example's MD file. + index_md_file = HERE / "gallery/examples" / self.name / "index.md" md_fm = frontmatter.load(index_md_file) self.title = md_fm.get("title", "") + self.author = md_fm.get("author", "") self.subtitle = md_fm.get("subtitle", "") md = MarkdownIt() self.description = str(md.render(md_fm.content)) # Main, extra head example's HTML file. - index_html_file = HERE / "gallery/examples" / self.path / "index.html" + index_html_file = HERE / "gallery/examples" / self.name / "index.html" if not index_html_file.exists(): # pragma: nocover - raise ValueError(f"No example at {self.path}") + raise ValueError(f"No example at {self.name}") soup = BeautifulSoup(index_html_file.read_text(), "html5lib") self.extra_head = get_head_nodes(soup) self.body = get_body_content(soup) +@dataclass +class Author(Resource): + """Information about an author, from Markdown.""" + + def __post_init__(self) -> None: + """Initialize the rest of the fields from the Markdown.""" + md_file = HERE / "gallery/authors" / f"{self.name}.md" + md_fm = frontmatter.load(md_file) + self.title = md_fm.get("title", "") + md = MarkdownIt() + self.body = str(md.render(md_fm.content)) + + @dataclass class Page(Resource): """A Markdown+frontmatter driven content page.""" subtitle: str = "" - body: str = "" def __post_init__(self) -> None: """Extract content from either Markdown or HTML file.""" - md_file = HERE / "pages" / f"{self.path}.md" - html_file = HERE / "pages" / f"{self.path}.html" + md_file = HERE / "pages" / f"{self.name}.md" + html_file = HERE / "pages" / f"{self.name}.html" - # If this self.path resolves to a Markdown file, use it first + # If this self.name resolves to a Markdown file, use it first if md_file.exists(): md_fm = frontmatter.load(md_file) self.title = md_fm.get("title", "") @@ -146,40 +160,49 @@ def __post_init__(self) -> None: if body_node and isinstance(body_node, Tag): self.body = body_node.prettify() else: # pragma: no cover - raise ValueError(f"No page at {self.path}") + raise ValueError(f"No page at {self.name}") @dataclass class Resources: """Container for all resources in site.""" - examples: dict[PurePath, Example] = field(default_factory=dict) - pages: dict[PurePath, Page] = field(default_factory=dict) + authors: dict[str, Author] = field(default_factory=dict) + examples: dict[str, Example] = field(default_factory=dict) + pages: dict[str, Page] = field(default_factory=dict) -def get_sorted_examples() -> list[PurePath]: +def get_sorted_paths(target_dir: Path, only_dirs: bool = True) -> list[PurePath]: """Return an alphabetized listing of the examples.""" - examples_dir = HERE / "gallery/examples" - examples = [e for e in examples_dir.iterdir() if e.is_dir()] - return sorted(examples, key=attrgetter("name")) + if only_dirs: + paths = [e for e in target_dir.iterdir() if e.is_dir()] + else: + paths = [e for e in target_dir.iterdir()] + + return sorted(paths, key=attrgetter("name")) def get_resources() -> Resources: """Factory to construct all the resources in the site.""" resources = Resources() + # Load the authors + authors = HERE / "gallery/authors" + for author in get_sorted_paths(authors, only_dirs=False): + this_author = Author(name=author.stem) + resources.authors[author.stem] = this_author + # Load the examples - for example in get_sorted_examples(): - this_path = PurePath(example.name) - this_example = Example(path=this_path) - resources.examples[this_path] = this_example + examples = HERE / "gallery/examples" + for example in get_sorted_paths(examples): + this_example = Example(example.stem) + resources.examples[example.stem] = this_example # Load the Pages pages_dir = HERE / "pages" pages = [e for e in pages_dir.iterdir()] for page in pages: - this_path = PurePath(page.stem) - this_page = Page(path=this_path) - resources.pages[this_path] = this_page + this_page = Page(name=page.stem) + resources.pages[page.stem] = this_page return resources diff --git a/src/psc/templates/author.jinja2 b/src/psc/templates/author.jinja2 new file mode 100644 index 0000000..8547fa8 --- /dev/null +++ b/src/psc/templates/author.jinja2 @@ -0,0 +1,8 @@ +{% extends "layout.jinja2" %} +{% block extra_head %} +{% block main %} +
+

{{ title }}

+
{{ body | safe }}
+
+{% endblock %} diff --git a/src/psc/templates/authors.jinja2 b/src/psc/templates/authors.jinja2 new file mode 100644 index 0000000..683613b --- /dev/null +++ b/src/psc/templates/authors.jinja2 @@ -0,0 +1,29 @@ +{% extends "layout.jinja2" %} +{% block main %} +
+
+

+ PyScript Authors +

+

+ All the contributors to authors and more. +

+
+
+
+
+ {% for author in authors %} +
+ +
+ {% endfor %} +
+
+{% endblock %} diff --git a/src/psc/templates/example.jinja2 b/src/psc/templates/example.jinja2 index a766282..1e3fa11 100644 --- a/src/psc/templates/example.jinja2 +++ b/src/psc/templates/example.jinja2 @@ -6,6 +6,9 @@ {% block main %}

{{ title }}

+ {% if author %} +

By {{ author.title }}

+ {% endif %}

{{ subtitle }}

{{ body | safe }}
diff --git a/src/psc/templates/gallery.jinja2 b/src/psc/templates/gallery.jinja2 index 40b1194..2f483fd 100644 --- a/src/psc/templates/gallery.jinja2 +++ b/src/psc/templates/gallery.jinja2 @@ -11,20 +11,22 @@
-
- {% for example in examples %} -
- -
- {% endfor %} -
+ {% for row in examples | batch(3) %} +
+ {% for example in row %} +
+ +
+ {% endfor %} +
+ {% endfor %}
{% endblock %} diff --git a/src/psc/templates/layout.jinja2 b/src/psc/templates/layout.jinja2 index 605d572..2f36559 100644 --- a/src/psc/templates/layout.jinja2 +++ b/src/psc/templates/layout.jinja2 @@ -26,6 +26,9 @@ Gallery + + Authors + Join diff --git a/tests/test_author_pages.py b/tests/test_author_pages.py new file mode 100644 index 0000000..58b2d64 --- /dev/null +++ b/tests/test_author_pages.py @@ -0,0 +1,24 @@ +"""Use the routes to render listing of authors and each one.""" +from psc.fixtures import PageT + + +def test_authors_page(client_page: PageT) -> None: + """The listing of authors works.""" + soup = client_page("/authors/index.html") + page_title = soup.select_one("title") + assert page_title and "Authors | PyScript Collective" == page_title.text + + authors = soup.select_one("article.tile p.title") + if authors: + assert "Margaret" == authors.text.strip() + + +def test_author_page(client_page: PageT) -> None: + """The page for an author works.""" + soup = client_page("/authors/meg-1.html") + page_title = soup.select_one("title") + assert page_title and "Margaret | PyScript Collective" == page_title.text + + author = soup.select_one("main h1") + if author: + assert "Margaret" == author.text.strip() diff --git a/tests/test_resources.py b/tests/test_resources.py index 8c12619..bfc7460 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,16 +1,17 @@ """Construct the various kinds of resources: example, page, contributor.""" from pathlib import Path -from pathlib import PurePath import pytest from bs4 import BeautifulSoup +from psc.here import HERE from psc.resources import Example from psc.resources import Page +from psc.resources import Resources from psc.resources import get_body_content from psc.resources import get_head_nodes from psc.resources import get_resources -from psc.resources import get_sorted_examples +from psc.resources import get_sorted_paths from psc.resources import is_local from psc.resources import tag_filter @@ -30,6 +31,12 @@ def head_soup() -> BeautifulSoup: return BeautifulSoup(head, "html5lib") +@pytest.fixture(scope="module") +def resources() -> Resources: + """Cache the generation of resources for this test file.""" + return get_resources() + + def test_tag_filter(head_soup: BeautifulSoup) -> None: """Helper function to filter link and script from head.""" excluded_link = head_soup.select("link")[0] @@ -110,12 +117,12 @@ def test_get_py_config_no_body() -> None: def test_example_bad_path() -> None: """Point at an example that does not exist, get ValueError.""" with pytest.raises(FileNotFoundError): - Example(path=PurePath("XXXX")) + Example(name="XXX") def test_example() -> None: """Construct an ``Example`` and ensure it has all the template bits.""" - this_example = Example(path=PurePath("hello_world")) + this_example = Example(name="hello_world") assert this_example.title == "Hello World" assert ( this_example.subtitle @@ -127,14 +134,14 @@ def test_example() -> None: def test_markdown_page() -> None: """Make an instance of a Page resource and test it.""" - this_page = Page(path=PurePath("about")) + this_page = Page(name="about") assert this_page.title == "About the PyScript Collective" assert "

Helping" in this_page.body def test_html_page() -> None: """Make an instance of a .html Page resource and test it.""" - this_page = Page(path=PurePath("contributing")) + this_page = Page(name="contributing") assert this_page.title == "Contributing" assert this_page.subtitle == "How to get involved in the PyScript Collective." assert 'id="viewer"' in this_page.body @@ -142,7 +149,7 @@ def test_html_page() -> None: def test_page_optional_subtitle() -> None: """Frontmatter does not specify a subtitle.""" - this_page = Page(path=PurePath("contact")) + this_page = Page(name="contact") assert this_page.title == "Contact Us" assert this_page.subtitle == "" @@ -150,33 +157,34 @@ def test_page_optional_subtitle() -> None: def test_missing_page() -> None: """Make a missing Page resource and test that it raises exception.""" with pytest.raises(ValueError) as exc: - Page(path=PurePath("xxx")) + Page(name="xxx") assert str(exc.value) == "No page at xxx" def test_sorted_examples() -> None: - """Ensure a stable listing.""" - examples = get_sorted_examples() + """Ensure a stable listing of dirs.""" + examples = get_sorted_paths(HERE / "gallery/examples") first_example = examples[0] assert "altair" == first_example.name -def test_get_resources() -> None: - """Ensure the dict-of-dicts is generated with PurePath keys.""" - resources = get_resources() +def test_sorted_authors() -> None: + """Ensure a stable listing of files.""" + authors = get_sorted_paths(HERE / "gallery/authors", only_dirs=False) + first_author = authors[0] + assert "meg-1.md" == first_author.name + +def test_get_resources(resources: Resources) -> None: + """Ensure the dict-of-dicts is generated with PurePath keys.""" # Example - hello_world_path = PurePath("hello_world") - hello_world = resources.examples[hello_world_path] - assert hello_world.title == "Hello World" - assert ( - hello_world.subtitle - == "The classic hello world, but in Python -- in a browser!" - ) + interest_calculator = resources.examples["interest_calculator"] + assert interest_calculator.title == "Compound Interest Calculator" + assert interest_calculator.subtitle == "Enter some numbers, get some numbers." + assert "meg-1" == interest_calculator.author # Page - about_path = PurePath("about") - about = resources.pages[about_path] + about = resources.pages["about"] assert about.title == "About the PyScript Collective" assert "

Helping" in about.body @@ -186,3 +194,10 @@ def test_is_local_broken_path() -> None: test_path = Path("/xxx") actual = is_local(test_path) assert not actual + + +def test_authors(resources: Resources) -> None: + """Get the list of authors as defined in Markdown files.""" + authors = resources.authors + first_author = list(authors.values())[0] + assert "meg-1" == first_author.name