Skip to content

Creating Theseus-style pages

Anything you create using Theseus revolves around schemas. The first thing you have to do before writing any functional code is to either identify an existing schema that can be used as-is or extended, or write a schema (or schemas) that support the content you are working with.

Requirements

You should not write any code unless you have documented requirements. There should be a PRD (product requirements document) that at minimum details what content we expect to work with. Everyone involved - developers, designers, product managers, stakeholders, etc - should feel like they understand the content.

Functionality should also be documented in the PRD, and URLs should be designed that logically organize the content.

It can be helpful to start writing schemas as soon as we feel like we have basic content definitions, because codifying the content often raises questions we didn't anticipate when writing the PRD.

Writing schemas

If you are writing a new schema or extending an existing schema, decide whether the schema is appropriate for inclusion in theseus_core (this repo) or if it should be defined in the project you're working on. The decision is usually simple: if there's a chance that another website will need to work with the same kind of content, or a variation of the content, you should consider writing schemas in theseus_core. If the content is specific to a given project, write the schemas in that project's repo.

When you are creating a website, most of the time you'll be extending existing schemas.

The first two schemas we're interested in are theseus_core.web.Page and theseus_core.web.Card.

A Page is the minimum set of fields you need to refer to a webpage. It defines the title, slug, and page_type. (There are other fields here, but they're not strictly relevant to this guide.)

A Card extends Page, adding a canonical_image and summary. This is enough for the front end to construct a card, e.g. for use in search results.

We can build out page content from this foundation.

Example: an Article page

Let's say we're creating a page for articles. We'll create schemas, database models, CMS configuration, and set up search index management.

Schemas

There are many schemas that will typically be used in article bodies, but this site has a special requirement: they want to be able to create an 'admonition': a specially-formatted bit of content that raises a warning for the reader, e.g. when content in the article is potentially not suitable for children or discusses distressing topics.

In fact, our docs support admonitions via a special markdown syntax. Here's an admonition:

Watch out!

Just kidding, there's no danger here.

Let's write a schema for an Admonition:

from typing import Literal

from pydantic import BaseModel

from theseus_core.markup import ATJSON


class Admonition(BaseModel):
    heading: str
    text: ATJSON | None = None
    contentType: Literal[
        "application/vnd.theseus+admonition"
    ] = "application/vnd.theseus+admonition"

That contentType attribute might look strange1. It has two purposes:

  1. It tells any front end code encountering this data what the data is, which makes it easy to dispatch.
  2. When this schema is used in a Union, we can use this field in a discriminated union. This has performance benefits and prevents mistakenly casting data into the wrong schema.

Let's set up the schemas we'll use for the body of our article:

from typing import Annotated
from typing import Literal
from typing import Union

from pydantic import BaseModel
from pydantic import Field

from theseus_core.image import Image
from theseus_core.image import ImageGallery
from theseus_core.markup import ATJSON
from theseus_core.markup import Blockquote
from theseus_core.markup import ListicleSection
from theseus_core.markup import Pullquote
from theseus_core.video import PBSVideo
from theseus_core.video import YouTubeVideo


class Admonition(BaseModel):
    heading: str
    text: ATJSON | None = None
    contentType: Literal[
        "application/vnd.theseus+admonition"
    ] = "application/vnd.theseus+admonition"


ArticleBody = Annotated[
    Union[
        ATJSON,
        Pullquote,
        Blockquote,
        Image,
        PBSVideo,
        YouTubeVideo,
        ImageGallery,
        ListicleSection,
        Admonition,
    ],
    Field(discriminator="contentType"),
]

OK, now we're ready to write a complete schema for our article. You might be tempted to extend the Card schema, but that would be a mistake. Card.contentType is permanently set to "application/vnd.theseus+card", which makes no sense in the context of a full schema. Instead, we'll build a schema from scratch:

class Article(BaseModel):
    title: str
    slug: str
    page_type: str = "article"
    canonical_image: Image | None = None
    summary: ATJSON | None = None
    body: list[ArticleBody]

We'll assume you already have a working Wagtail site with theseus_core and theseus_wagtail installed, and you've created a Django app for your article page. Place the Article schema definition in a schemas.py file in your app.

Page model

In your models.py, create the basic modeling and CMS configuration:

from wagtail.admin.panels import FieldPanel
from wagtail.models import Page
from wagtail.fields import StreamField

from theseus_wagtail.image.blocks import ImageGalleryBlock
from theseus_wagtail.image.blocks import PositionedImageBlock
from theseus_wagtail.markup.blocks import BlockquoteBlock
from theseus_wagtail.markup.blocks import ListicleSectionBlock
from theseus_wagtail.markup.blocks import PullquoteBlock
from theseus_wagtail.markup.blocks import TheseusRichTextBlock
from theseus_wagtail.web.models import TheseusPage


class Article(TheseusPage):
    body = StreamField(
        [
            ("paragraph", TheseusRichTextBlock()),
            ("pullquote", PullquoteBlock()),
            ("blockquote", BlockquoteBlock()),
            ("image", PositionedImageBlock()),
            ("gallery", ImageGalleryBlock()),
            ("listicle_items", ListicleSectionBlock()),
            ("admonition", AdmonitionBlock()),
        ],
        use_json_field=True,
    )

    content_panels = Page.content_panels + [
        FieldPanel("canonical_image"),
        FieldPanel("summary"),
        FieldPanel("body"),
    ]

    promote_panels = TheseusPage.promote_panels

StreamField and Theseus-compatible blocks

We want to cast all of the content from our article body into our schemas, but Wagtail wants to render StreamField blocks as HTML. When we get a StreamField value from the database, it's an iterable of BoundBlock objects, which is basically a namedtuple with block and value attributes. The block attribute is the block class, and value is specific for the block class. Read the Wagtail documentation for more about this, and dig into the source if you want a deeper understanding.

We don't have a block for our Admonition schema yet, so we'll have to create one, but let's start with a simple example.

Wagtail has a CharBlock that is analogous to Django CharField. It stores plain text and the value it returns a str. We still need to add a special classmethod anyway, so we can iterate over a StreamField value and get the value in the same way as we do any other.

from wagtail.blocks import CharBlock

class TheseusCharBlock(CharBlock):
    @classmethod
    def theseus_value(cls, value):
        return value

Our blocks must have a theseus_value classmethod.

Let's make a block for our Admonition schema so we can add it to our article body.

from wagtail.blocks import CharBlock
from wagtail.blocks import StructBlock

from theseus_wagtail.markup.blocks import TheseusRichTextBlock


class AdmonitionBlock(StructBlock):
    heading = CharBlock()
    text = TheseusRichTextBlock(
        features=["bold", "italic"],
        required=False,
    )

    @classmethod
    def theseus_value(cls, value):
        return Admonition(
            heading=value["heading"],
            text=TheseusRichTextBlock.theseus_value(value["text"]),
        )

A StructBlock is like a little model in itself, defining a set of fields as sub-blocks. Read all about blocks in the Wagtail documentation.

Now, update the Article model's body with this block:

class Article(TheseusPage):
    body = StreamField(
        [
            ("paragraph", TheseusRichTextBlock()),
            ("pullquote", PullquoteBlock()),
            ("blockquote", BlockquoteBlock()),
            ("image", PositionedImageBlock()),
            ("gallery", ImageGalleryBlock()),
            ("listicle_items", ListicleSectionBlock()),
            ("admonition", AdmonitionBlock()),
        ],
        use_json_field=True,
    )

Add a theseus_value method to the Article model

Obviously, we want to use this schema to deliver data for the entire article to the front end, but we also want to be able to return theseus_core.web.Page and theseus_core.web.Card values. We might need to deliver other variations as well. To do this, we use typing.overload and define literal values to be provided to a return_type keyword argument.

from articles import schemas

class Article(TheseusPage):
    # fields, panels, and Schema excluded here

    @overload
    def theseus_value(self, return_type: Literal["page"]) -> theseus_core.web.Page:
        ...

    @overload
    def theseus_value(self, return_type: Literal["card"]) -> theseus_core.web.Card:
        ...

    @overload
    def theseus_value(self, return_type: Literal["full"]) -> schemas.Article:
        ...

    def theseus_value(self, return_type: str = "full"):
        match return_type:
            case "page":
                return theseus_core.web.Page(
                    title=self.title,
                    slug=self.slug,
                    page_type="article",
                )
            case "card":
                return theseus_core.web.Card(
                    title=self.title,
                    slug=self.slug,
                    page_type="article",
                    canonical_image=self.get_canonical_image(),
                    summary=self.get_summary(),
                )
            case "full":
                body = [item.block.theseus_value(item.value) for item in self.body]
                return schemas.Article(
                    title=self.title,
                    summary=self.get_summary(),
                    slug=self.slug,
                    canonical_image=self.get_canonical_image(),
                    body=body,
                )

Import the schemas file to differentiate the two Article class definitions

We use a match statement here to prevent each variant from be generated every time we need to get a value.

The get_canonical_image and get_summary methods are provided by TheseusPage and return instances of the schemas for those fields.

Notice how we build the body value by iterating over the StreamField value and calling the theseus_value classmethod from each block, passing in the value for each instance.

API output via FastAPI

Now we can make API endpoints for our Article pages. Add a router for articles to be hooked into your FastAPI app:

from fastapi import APIRouter
from fastapi import HTTPException
from theseus_core.web import Card

from articles import models
from articles import schemas

router = APIRouter()


@router.get(
    "/",
    response_model=list[Card],
    response_model_exclude_none=True,
    tags=["articles"],
)
def root():
    articles = models.Article.objects.live()
    return [article.theseus_value(return_type="card") for article in articles]


@router.get(
    "/{slug}",
    response_model=schemas.Article,
    response_model_exclude_none=True,
    tags=["articles"],
)
def detail(slug: str):
    try:
        return models.Article.objects.get(slug=slug, live=True).theseus_value()
    except models.Article.DoesNotExist as exc:
        raise HTTPException(status_code=404, detail="Article not found") from exc
    except models.Article.MultipleObjectsReturned as exc:
        raise HTTPException(
            status_code=500, detail="Multiple articles found!)"
        ) from exc

Import the schemas and models files to differentiate the two Article class definitions

We can get a listing of articles as Card objects via the root function, and the full representation from the detail method.

Automatic search indexing

Back in our Article model, we can add a few methods that will run when a page is published or unpublished to insert/update or delete data from an ElasticSearch index.

from theseus_wagtail.search.base import ElasticBase

class Article(TheseusPage):
    # fields, panels, Schema, and theseus_value excluded

    def update_search(self):
        document = ElasticBase(
            title=self.title,
            page_type="article",
            card=self.theseus_value(return_type="card").dict(),
            content=str(self.body),
        )
        document.meta.id = f"article-{self.pk}"
        document.save()

    def remove_from_search(self):
        document = ElasticBase.get(id=f"article-{self.pk}", ignore=404)
        if document:
            document.delete()

You can extend the ElasticBase document if you need to be able to add additional parameters, e.g. for pages that support free tagging or structured metadata like a list of topics.

Override some Wagtail Page methods

Wagtail expects URLs on your site to be dictated by the page tree, but the page tree does not always map onto our URL structure. For links to work as expected on rendered pages, we have to override the get_url and get_full_url methods, and re-point the url and full_url properties at our versions.

class Article(TheseusPage):
    # fields, panels, Schema, theseus_value, and search methods excluded

    def get_url(self):
        return f"/article/{self.slug}"

    def get_full_url(self):
        return f"{settings.BASE_URL}/{self.get_url()}"

    url = property(get_url)

    full_url = property(get_full_url)

settings.BASE_URL is environment-specific and includes the scheme and hostname for the final URL.


  1. The use of camel case on contentType is due to its origin in ATJSON. Since the schema for ATJSON is beyond our control, we adopt the case it uses. The value for this field also mimics ATJSON's MIME-like application/vnd.atjson+offset