Skip to main content

django-mvp-sphinx

Serve a project's Sphinx documentation inside its django-mvp application shell.

Scope & philosophy

django-mvp-sphinx serves a project's Sphinx documentation as pages of the project itself. The docs render through the project's own templates and theme, inside its application shell, and their table of contents becomes the app sidebar. Reading the user guide doesn't mean leaving the application.

It is written first for the people using the application: user guides, how-tos and reference for the site itself. Developer documentation should work too, but where the two pull in different directions, the user guide wins.

It serves a Sphinx JSON build (sphinx-build -b json). Serving never needs Sphinx installed and never starts a build: the build is a file the project produces before a request arrives, however it already does that. The package adds a management command that runs the build when you ask for it. It does not host documentation for several projects, keep old versions, or manage translations.

When two designs conflict, the one that makes the docs look like the rest of the site beats the one that copies a Sphinx theme.

Quickstart

Five steps take a project from install to a documentation page in its own application shell, with the contents in the sidebar and a menu entry that leads to it. You write no template.

Before you start

You need a Django project on django-mvp whose application shell already works. This package renders inside django-mvp's layout and reads its colours from the theme django-mvp supplies, so it does nothing useful on its own.

You also need a Sphinx source directory for your user guide. The examples call it docs/. If you don't have one yet, Sphinx's own sphinx-quickstart creates it.

The examples use yourproject and yourapp for your project's package and one of its apps. Change those names and the paths to match your own.

1. Install

pip install django-mvp-sphinx

Then add it to INSTALLED_APPS, after mvp:

INSTALLED_APPS = [
    # ...
    "mvp",
    "mvp_sphinx",
]

2. Add the Sphinx line

Add the package's extension to your Sphinx project's docs/conf.py:

extensions = ["mvp_sphinx.navigation"]

If conf.py already lists extensions, add this one to the list.

The extension writes the contents the sidebar shows. Without it the sidebar holds only the front page's entry, and every page is still served.

3. Build the docs

sphinx-build -b json docs docs/_build/json

Sphinx is needed where you run that command and nowhere else. The site that serves the pages reads the files the build left behind, never imports Sphinx and never starts a build, so Sphinx can stay out of your production requirements.

The output folder is yours to choose. A build made by a CI step, or one kept outside your project, works the same way once step 4 points at it. Until a build exists, every address under the prefix answers 404 and the rest of your site is unaffected.

Once the docs are mounted, python manage.py build_docs can run this build for you. See Building with a management command.

4. Mount the docs

Create the app once, in a module of its own, and give it the build's folder as build_dir:

# yourproject/mounted.py
from django.conf import settings
from mvp_sphinx.mounted import DocumentationApp

docs = DocumentationApp(build_dir=settings.BASE_DIR / "docs" / "_build" / "json")

Then mount it in your URLs under whatever prefix you like:

# yourproject/urls.py
from mvp.mounted import mount

from yourproject.mounted import docs

urlpatterns = [
    mount("docs/", docs),
]

5. Add the menu entry

Add the app's entry to your menu, in the menus.py of one of your installed apps:

# yourapp/menus.py
from mvp.menus import AppMenu

from yourproject.mounted import docs

AppMenu.append(docs.menu_item())

django-flex-menus imports each installed app's menus module when Django starts, which is why the file has to live in an installed app.

What you now have

Each page of the build answers under docs/, drawn by your own base.html inside the application shell. The front page is at /docs/, the contents are in the app sidebar, and your menu has an entry that leads to the front page. The tab title carries the page's title and the app's name (Documentation unless you change it), and the breadcrumbs lead back through the page's parents to the front page.

Changing the docs

Edit your Sphinx source, run the command from step 3 again, and reload the page. The pages, the contents and the search follow on the next request, with no restart.

Using it

Everything below is optional. The quickstart is all a project needs to serve its documentation.

Building with a management command

To build the docs the way you run your other maintenance tasks, tell the app where its Sphinx source is with source_dir, the directory that holds conf.py:

from django.conf import settings
from mvp_sphinx.mounted import DocumentationApp

docs = DocumentationApp(
    build_dir=settings.BASE_DIR / "docs" / "_build" / "json",
    source_dir=settings.BASE_DIR / "docs",
)

Then build it:

python manage.py build_docs

That runs Sphinx's JSON build from source_dir into build_dir, as sphinx-build -b json would, so the build lands where the app already reads it. With no arguments the command builds every mounted documentation app that has a source_dir, in the order they are mounted, and leaves the others alone. Name one or more apps by namespace to build only those:

python manage.py build_docs handbook

The command stops with an error, and a non-zero exit status, when Sphinx reports a failed build, when a namespace belongs to no mounted documentation app, when a named app has no source_dir, and when no mounted app has one. A failed build stops the ones after it. --verbosity 0 prints warnings and errors only.

Sphinx has to be installed where you run the command, exactly as for sphinx-build. Nothing else changes: the site still never imports Sphinx and never starts a build to answer a request, so a page is only ever as new as the last build you ran. source_dir is read by this command and nothing else, and an app without one is served as before.

The contents in the sidebar

When sphinx-build -b json finishes, the extension you added in step 2 of the quickstart writes a navigation.json into the build with every page your toctrees list, hidden toctrees included. The documentation app's menu, a DocumentationMenu, draws it as the sidebar menu on every page of the docs.

  • Each captioned toctree on your front page becomes a group named by its caption. An uncaptioned toctree puts its pages at the top level.
  • A page with pages of its own opens as a group, and its first entry is the page itself.
  • The front page has its own entry at the top, under its own title. A navigation.json written before the extension recorded that title labels it "Overview".
  • A page no toctree lists stays out of the sidebar.

Only the JSON builder gets a file, and a build that failed writes nothing.

Rebuild the docs and the sidebar shows the change on the next request, with no restart.

The extension runs inside your Sphinx build and nowhere else, so the site that serves the pages still doesn't need Sphinx. Without the line, or with a navigation.json that can't be read, the sidebar holds only the front page entry and every page is still served.

Adding your own entries to the sidebar

The contents come from the build, and you can add entries of your own beside them. This matters most when the docs are your project's main app, mounted with main=True: django-mvp then draws the documentation menu on every page that belongs to no other app and leaves AppMenu out, so this menu is where links to your other apps and pages go.

Add them to the documentation app's menu, in the menus.py of one of your installed apps:

# yourapp/menus.py
from flex_menu import MenuItem

from yourproject.mounted import docs, studio

docs.menu.append(studio.menu_item())
docs.menu.insert(
    MenuItem(name="home", view_name="home", extra_context={"label": "Home"}),
    position=0,
)
  • An entry you append or extend follows the contents.
  • An entry you insert ahead of the front page's entry stays ahead of it. Inserted anywhere else, it follows the contents after the next rebuild.
  • Your entries keep their places when the docs are rebuilt. A rebuild replaces only the entries that came from navigation.json.
  • They behave like menu entries everywhere else: an entry whose check refuses the request isn't drawn.

The page's own headings

On a wide screen, each page lists its own headings beside it, under "On this page", and the page's text widens into the room beside the list. The list is nested the way the page nests its sections, each entry links to its heading, and it stays in view below the top bar as the page scrolls. Sphinx already records that tree in the build, so there is nothing to configure and the site that serves the pages still doesn't need Sphinx.

  • The page's title is not listed, and neither are the headings of other pages, so a front page that only holds a toctree lists nothing from the pages it links to. The sidebar keeps the contents.
  • A page with no headings below its title shows no list at all.
  • Sphinx's :tocdepth: setting decides how deep the list goes.

PageView reads the tree with PageHeadings.from_toc(toc), which turns a page's toc value from the build into nested dicts of title, anchor and children. Call it yourself if you draw the headings in a template of your own.

Rebuild the docs and the list follows on the next request.

Every page also ends with links to the page before it and the page after it, in the order Sphinx puts the pages in, each showing the title of the page it leads to. The front page has no previous link, the last page has no next link, and a page that no toctree lists has neither. The links stay inside the documentation app, so a second app mounted elsewhere links under its own address. An incremental Sphinx build only rewrites the pages that changed and the pages whose toctrees changed, so a page's links follow a newly inserted neighbour once that page is rebuilt. Run a full rebuild (-E) if in doubt.

Every page of a documentation app has a search box. It lists the pages of that docs build that contain all the words typed, in any order and any case, and finds other forms of a word too, so lanterns finds a page that says "lantern". It searches that one app's pages and never the rest of your site or another documentation app. Adding a word narrows the list. Common words such as "the" are ignored, as Sphinx ignores them.

The page whose title holds all the words comes first, then a page with a section heading that holds them, then the rest, each group in order of title. Every result shows the page's title and, when the page's text holds one of the words, a short passage around it. A result found by a section heading links to that section.

Nothing needs configuring. The search reads the search data Sphinx already writes into every JSON build (searchindex.json), so there is no extra Sphinx setting and no Sphinx where the site runs. The only extra dependency is snowballstemmer, the stemmer Sphinx itself uses to build that data.

The box is an ordinary form that sends GET to the app's search/ address, for example /docs/search/?q=lantern, so it works with scripts turned off and a search can be bookmarked or shared. That address is where Sphinx's own search page would be, so a :ref: link to search in your docs lands on it. Whoever may read the pages may search them, under the same check.

The search/ address is reserved under every documentation app, so a folder of your docs named search can't have its own index page there. Name that folder something else.

The search data is read on each search, so a rebuild is searchable straight away. A build without it still serves its pages, and the results page says search is unavailable.

Naming the documentation

The app's name is what the tab title, the first breadcrumb and the menu entry call the documentation. Give it your own when "Documentation" isn't right:

from django.conf import settings
from django.utils.translation import gettext_lazy as _

docs = DocumentationApp(
    build_dir=settings.BASE_DIR / "docs" / "_build" / "json",
    name=_("Administrator's handbook"),
)

Choosing who can read it

The documentation is open to everyone unless you say otherwise. To keep it for signed-in people, import user_is_authenticated from flex_menu.checks and pass it as check:

from django.conf import settings
from flex_menu.checks import user_is_authenticated

docs = DocumentationApp(
    build_dir=settings.BASE_DIR / "docs" / "_build" / "json",
    check=user_is_authenticated,
)

That is one import and one keyword. A reader the rule excludes gets no menu entry and no page. An anonymous visitor is sent to your sign-in page and comes back to the address they asked for once signed in. The rule covers every address under the prefix, including the images and downloads your pages link to, and it answers the same way whatever is behind the address, so a reader cannot tell which pages exist.

To keep it for a group or for holders of a permission, use the other two checks from flex_menu.checks. Both are factories, so call them with their arguments:

from django.conf import settings
from django.utils.translation import gettext_lazy as _
from flex_menu.checks import user_has_any_permission, user_in_any_group

docs = DocumentationApp(
    build_dir=settings.BASE_DIR / "docs" / "_build" / "json",
    check=user_in_any_group("Support"),
)
tickets = DocumentationApp(
    build_dir=settings.BASE_DIR / "tickets" / "_build" / "json",
    name=_("Ticket handling"),
    namespace="tickets",
    check=user_has_any_permission("support.view_ticket"),
)

Any function of the request works too:

def staff_only(request):
    return request.user.is_staff


handbook = DocumentationApp(
    build_dir=settings.BASE_DIR / "handbook" / "_build" / "json",
    namespace="handbook",
    check=staff_only,
)

A signed-in reader the rule excludes gets your project's 403 page, which says nothing about the documentation, and no menu entry. The rule alone decides. Staff and superusers have no way in unless it admits them, and check=False admits no one.

The rule is asked on every request, and often more than once in one, since the menu entry asks it as well as the page. Keep it quick and free of side effects. If it raises, the request is a server error: a page is never served on a guess. The menu entry asks the rule too, so the error also shows on every page that draws the entry, the sign-in page included. Write a rule that returns an answer.

A check that is not a function is read as yes or no. check="staff" is a non-empty string, which is true, and admits everyone. To keep the docs for staff, pass user_is_staff from flex_menu.checks, or a function of your own.

Two audiences are two apps. Each DocumentationApp has its own build, name, namespace and rule, and each is mounted at its own prefix:

from django.conf import settings
from django.utils.translation import gettext_lazy as _
from flex_menu.checks import user_is_staff

guide = DocumentationApp(
    build_dir=settings.BASE_DIR / "guide" / "_build" / "json",
)
staff_guide = DocumentationApp(
    build_dir=settings.BASE_DIR / "staff_guide" / "_build" / "json",
    name=_("Staff guide"),
    namespace="staff_guide",
    check=user_is_staff,
)
from mvp.mounted import mount

urlpatterns = [
    mount("guide/", guide),
    mount("staff-guide/", staff_guide),
]

Each reader sees the entries their rules admit and is served or refused by each app's own rule. The rule is asked again on every request, so a reader who signs in or joins a group is admitted by their next request, with nothing to reset.

The rule is django-mvp's check, described in its mounted apps guide.

Several documentation apps

Each build is one DocumentationApp with its own namespace (docs unless you say otherwise), mounted at its own prefix, which may have several segments. Each app serves only its own build and names only itself in its tabs and breadcrumbs.

handbook = DocumentationApp(
    build_dir=settings.BASE_DIR / "handbook" / "_build" / "json",
    name=_("Administrator's handbook"),
    namespace="handbook",
)

urlpatterns = [
    mount("docs/", docs),
    mount("manuals/admin/", handbook),
]

Add handbook.menu_item() to your menu beside docs.menu_item() to give each its own entry.

Addresses and files served

The images and downloads your pages link to (_images/ and _downloads/ in the build) are served at the addresses the pages already use, with the file's content type. Nothing else in the build is: not the search index, the page data, the sources, the static files or Sphinx's pickles. An address that tries to climb out of those two folders answers 404.

build_dir is read on every request. Rebuild the docs and reload the page to see the change, with no restart. It doesn't have to exist when the site starts, so a project that hasn't built its docs yet still boots. Until the build exists every address under the prefix answers 404, the rest of the site is unaffected, and the first request after the build appears is served.

Addresses behave like the rest of your site. A page address without its trailing slash redirects permanently to the slashed address, query string kept, and an address with no page answers your own 404 page. The bare prefix (/docs) is redirected by Django's CommonMiddleware (APPEND_SLASH), as for any other mount. A page file that is not valid JSON is a broken build and raises, so it is a server error rather than a 404.

How pages look and the page template

A PageView renders each page, and it finds the page's data through a DocsBuild, which only ever looks inside build_dir. To change how a page is drawn, subclass PageView and pass it to your app as view_class.

Pages take your site's theme with nothing to configure: the colours are django-mvp's own, so a page follows the light and dark themes and any theme your project defines. The package's page template links two stylesheets, served like the rest of your static files, and only pages the documentation app renders load them: mvp_sphinx/content.css styles what Sphinx writes into the page, and mvp_sphinx/page.css places the "On this page" list beside it. Sphinx's own stylesheets are never used.

Reference entries, the documented functions, classes and other objects that autodoc writes and that you write by hand with directives such as .. py:function::, are styled from the same theme with nothing to configure. Each signature sits in a bar in the code colours, its description hangs from a rule beneath it, and entries inside other entries read as inside them. This holds for any language Sphinx documents, and it makes no difference whether an entry was generated or typed.

A heading you follow a link to lands 5rem below the top of the window, and the "On this page" list sticks at the same distance, so both clear django-mvp's top bar. django-mvp doesn't publish the bar's height, so if yours is taller, with a tray or a second row, set the distance in your own stylesheet:

:root {
  --mvp-sphinx-header-clearance: 7rem;
}

Before a page is rendered, BodyRewriter adds what a stylesheet cannot. It wraps each table in a scrolling region that takes keyboard focus and is named by the table's caption, or "Table" when it has none, so a table wider than the page scrolls sideways for a reader using only a keyboard and a screen reader announces what the region holds. An equation set out on its own line gets the same treatment, named "Equation" or, when it is numbered, "Equation (1)", so a wide equation can be scrolled from the keyboard before and after it is typeset; its number stays outside the region. It also names each heading link (the ¶ Sphinx puts beside a section heading, a glossary term or a caption) with the link's own title and the heading's text, such as "Link to this heading: Installing", so a screen reader tells one link from the next. A reference entry's link is named by the entry and not by its whole signature, such as "Link to this definition: demo.links.page_address", so a page of entries reads as a list of names. Everything else in the body reaches the page exactly as Sphinx wrote it. PageView applies it and hands the result to the template as body, so a PageView subclass gets it too; to use it elsewhere, call BodyRewriter.rewrite(markup).

If you override mvp_sphinx/page.html, keep {{ block.super }} in its styles block so both stylesheets still reach the page, render {{ body }} rather than {{ page_data.body }} so your override keeps the rewrite, and keep the mvp-sphinx-content class on the element that holds it, because every rule in content.css is scoped to that class. An override of the content block also takes over drawing "On this page" and the previous and next links, which the page gets as headings, previous_page and next_page. BodyRewriter.rewrite returns a plain string; PageView marks it safe because the docs build is your own, and a caller of its own does the same:

{% extends "mvp_sphinx/page.html" %}
{% load static %}
{% block styles %}
  {{ block.super }}
  <link rel="stylesheet" href="{% static 'yourproject/docs.css' %}">
{% endblock styles %}

Maths

Notation written with the math role or directive is typeset by MathJax 4, in the reader's browser. Sphinx's own HTML build uses the same library from the same place, and so does this package: the page loads it from the jsDelivr CDN, at https://cdn.jsdelivr.net/npm/mathjax@4/tex-mml-chtml.js. Typeset maths takes its colours from your theme and follows light and dark. Notation MathJax cannot read, such as an unknown command, is shown in the theme's error colour and not in MathJax's red.

Only a page that holds maths loads the library, together with the small settings file mvp_sphinx/maths.js that tells it to look only inside what Sphinx marked as maths, so nothing else on the page, your own shell included, is read as notation. Every other page loads neither. It works for a docs build made with Sphinx's default settings (html_math_renderer left alone, so notation reaches the page as \(...\) and \[...\]). A build that renders maths as images shows the images. The library is still loaded on those pages and finds nothing to typeset.

If the reader's network has no outside access, or your site sends a content security policy that does not allow cdn.jsdelivr.net, the library does not load. (A policy has to allow that origin for scripts and fonts, and allow inline styles, for typeset maths to show.) The reader then sees the notation as written, in the code font, and the rest of the page is unaffected.

The script runs in your own pages, with the reader's session, signed-in readers included. The address follows MathJax's newest 4.x release and is loaded without an integrity check, so what runs can change without a release of this package. If your project does not accept a third-party script, or needs the library from another source, override mvp_sphinx/page.html and write your own extra_js block, which is where the two script tags are.

Live examples

A live example puts a page of your own site into a documentation page. The page runs in a frame, so a reader can use it, and the code that makes it sits beside the frame. A reader can fill in a form and see what your site answers without leaving the documentation.

Write one with the live-example directive. The extension you already named in conf.py provides it, so Sphinx needs no further setting. The argument is the address of the page on your site, a path that starts with a single /. The option :title: names the example, and the line below it names the file whose code to show, relative to the documentation page's own file:

.. live-example:: /examples/contact/
   :title: A contact form

   ../../examples/forms.py

Rebuild the docs and the page shows the frame with the file beside it. A page with an example loads one more stylesheet, mvp_sphinx/example.css; other pages load nothing new.

Each example has two links above the frame. "Start again" loads the example's address in the frame again, which puts the page back as it was when the reader arrived, and "Open on its own" opens the address as a page of its own. Both are plain links, and the tabs for several files are radio inputs, so all of them work with scripts off.

Several files, and parts of files

List more than one file, one to a line, and the reader gets a tab for each, in the order you wrote them. Follow a file with first-last, or with one line number, to show only those lines, counted from 1. The lines are shown with the indentation they all share removed, so a method reads flush left:

.. live-example:: /examples/contact/
   :title: A contact form

   ../../examples/forms.py
   ../../examples/views.py 12-25
   /templates/contact.html

A path is relative to the documentation page's own file, or starts with / to count from the documentation's source directory. A path may hold spaces. The last word is read as lines only when it is a number or two numbers joined by a hyphen. Line numbers do not follow edits to the file, so check them when you change it.

Each tab is named by its file. When two files of one example have the same name, each tab adds the parent folders it needs to tell them apart, as a/forms.py and b/forms.py. When one file is named twice with different lines, the lines are added to the name, as views.py 12-25. A file named once keeps its plain name, and an example with one file shows no tabs.

What the build reports

A marker the build cannot use is a Sphinx warning, at the line of the directive, so the message names the documentation page. The build then goes on without that part of the marker:

  • An address that is not a path of your site. It must start with one / and hold no scheme, host, backslash or space, so https://host/x, //host/x, /\host/x and host/x are all refused. The page gets no example and none of its sources.
  • A source file that does not exist, or that is not UTF-8 text. The message names the file, and the example keeps its other sources.
  • A range that is reversed, zero, or past the end of the file. The message names the file, and that source is left out. A last word that is not a number is part of the path, so it is reported as a file that does not exist.
  • An example left with no source at all. The page gets no example.

Run Sphinx with -W and each of these fails the build, so a typo in an address or a file that was renamed does not reach the published documentation.

Writing the example's page

The page in the frame is an ordinary page of your site, with an ordinary view. It would show your application shell inside the frame, so give it a template that extends mvp_sphinx/example.html and fills the content block:

{% extends "mvp_sphinx/example.html" %}
{% block content %}
  <c-form method="post" :form-obj="form">
    <c-button text="Send message" type="submit" variant="primary" />
  </c-form>
{% endblock content %}

mvp_sphinx/example.html extends your own base.html, so the page keeps your theme, stylesheets and scripts. It replaces only the app block, the one that holds the shell, with a main element that holds content and the messages. A page that extends a template with the shell still shows with the shell, inside the frame.

A form in an example posts to the example's own address, so the answer appears in the frame and the documentation page's address does not change. Have the view redirect to request.path after a valid post, and a message added with django.contrib.messages shows in the frame.

Letting your site frame itself

Django sends X-Frame-Options: DENY by default, and a browser then shows nothing in the frame. Set this in your settings:

X_FRAME_OPTIONS = "SAMEORIGIN"

It lets pages of your site be framed by pages of your site and by nobody else. It applies to every page, so it covers the sign-in page and the error pages too, which is what lets an example show them. If your site sends a Content-Security-Policy header with frame-ancestors, include 'self' in it. The package cannot check either setting for you, and a frame that is empty or shows the browser's own refusal usually means one of them is missing.

What the reader sees

The example is your site's own page at its own address, so your own rule for that address decides who sees what. A reader your site sends to sign in, or refuses, sees that answer in the frame. The documentation app's reader rule decides who reads the documentation page, and with it the source shown beside the frame.

The source is a copy made when the docs are built. Change the file and rebuild to refresh it. The page in the frame is always your site's current page.

If the site has no page at the address when a documentation page is served, the page says so in place of the frame, and the source is still shown.

Public surface

These are the names a project can use, grouped the way a project meets them.

Installed app and Sphinx extension

  • mvp_sphinx is the Django app. Add it to INSTALLED_APPS after mvp.
  • mvp_sphinx.navigation is the Sphinx extension. It writes navigation.json into a JSON build and adds the live-example directive.

The documentation app

  • mvp_sphinx.mounted.DocumentationApp serves one docs build under the prefix you mount it at. Its keyword options are build_dir (required), source_dir, name, icon, namespace, view_class and check.
  • menu_item(), inherited from django-mvp's MountedApp, returns the entry to add to your own menus.
  • menu is the app's DocumentationMenu, which draws the contents as the app sidebar.
  • Its URL names are <namespace>:front_page, <namespace>:page (which takes path) and <namespace>:search.

Management command

  • build_docs runs the Sphinx JSON build from each documentation app's source_dir into its build_dir. It takes the namespaces of the apps to build, and builds every app that has a source_dir when given none.

Views

  • mvp_sphinx.views.PageView renders a page. Subclass it and pass the subclass as view_class to change how a page is drawn. A subclass may override template_name, get_context_data(), get_page_title(), get_headings(), get_neighbour(key) (key is "prev" or "next") and get_breadcrumbs().
  • mvp_sphinx.views.SearchView renders the results of a search.

Building blocks

For a custom view or template:

  • mvp_sphinx.docs_build.DocsBuild(root) reads a docs build and never looks outside it. page(path) returns a page's data or None, file(path) returns an image or download under _images/ or _downloads/ or None, and navigation() returns the entries of navigation.json or None, front_page_title() returns the root document's title it records or None, and navigation_file() returns the whole file or None.
  • mvp_sphinx.menus.DocumentationMenu turns a build's navigation file into the sidebar menu. front_page is the front page's entry, front_page_url(request) is the address it links to, and contents lists the entries the last rebuild read from the file.
  • mvp_sphinx.headings.PageHeadings, through PageHeadings.from_toc(toc), turns a page's toc value into nested headings.
  • mvp_sphinx.page_body.BodyRewriter, through BodyRewriter.rewrite(markup), names table regions, equation regions and heading links in a page body. BodyRewriter.parse(markup) returns the parser itself: splice() gives the same rewritten body, and has_maths says whether the body holds maths. mvp_sphinx.page_body.Equation is the parser's own record of where one equation's number and notation sit while it reads, and nothing a project needs to build.
  • mvp_sphinx.search.DocsSearch(build), given a DocsBuild, searches it. results(query) lists the pages that hold every word of query, best match first, each with its title, path, anchor and passage, or returns None when the build has no usable search data.
  • mvp_sphinx.search.PageText, through PageText.text(markup), gives the text a reader sees in a page body, with its whitespace collapsed.
  • mvp_sphinx.examples.LiveExamples, through LiveExamples.parts(body), splits a rewritten page body into the markup and the live examples in it, in order. Each part is {"html": ...} or {"example": ...}, and an example holds its id, title, address, available and sources. PageView passes the result to the template as body_parts. mvp_sphinx.examples.ExampleReader is the parser parts reads with, and nothing a project needs to build.

Templates a project may override

  • mvp_sphinx/page.html draws a page. It receives body, body_parts, has_examples, headings, previous_page, next_page, search_url and page_data. It has the title, styles and content blocks, and it holds the page's body in an element with the mvp-sphinx-content class.
  • mvp_sphinx/example.html is the base for a page of your site that a documentation page shows as a live example. It extends your base.html, draws no shell, and has the content block.
  • mvp_sphinx/search.html draws the results. It receives query, results and search_url, and has the title, styles and content blocks. results is None when the build has no search data, and otherwise a list of the pages found, each with its title, passage and href.

Components

Use these in your own templates as <c-mvp_sphinx.on_this_page /> and so on:

  • mvp_sphinx.on_this_page draws a page's headings. It takes headings.
  • mvp_sphinx.heading_list draws nested headings as a list. It takes headings.
  • mvp_sphinx.page_links draws the links to the previous and next page. It takes previous and next.
  • mvp_sphinx.search_form draws the search box. It takes action and query.
  • mvp_sphinx.live_example draws one live example, the frame and its source. It takes example, one {"example": ...} part from LiveExamples.parts.

Static files

  • mvp_sphinx/content.css is the stylesheet page content uses, and mvp_sphinx/page.css the one that places "On this page" and sets --mvp-sphinx-header-clearance.
  • mvp_sphinx/maths.js holds the settings for the maths typesetting. A page with maths loads it before the library.
  • mvp_sphinx/example.css sizes the live example's frame and its source. A page with an example loads it.

Settings

There are none. The package reads no Django setting, and everything is configured on the DocumentationApp.

Anything not listed here is internal and may change without notice.

Contributing

Standards for this repository live in CONSTITUTION.md, and the vocabulary to use in issues and commits lives in CONTEXT.md.

uv sync
uv run pytest
uv run pre-commit install

The demo

demo/ is a Django project on django-mvp's application shell, for looking at this package in a browser while you work on it. It serves a user guide of its own and a second guide for staff, each through a documentation app, and the guides between them hold every state this package draws.

The guides' builds are not committed, so build them before you start the server. build_docs is the package's own management command, and it builds both of the demo's guides:

uv sync
uv run python manage.py migrate
uv run python manage.py seed_demo
uv run python manage.py build_docs
uv run python manage.py runserver

Then open http://127.0.0.1:8000/docs/ for the user guide. Its sidebar reaches every page, and ends with an entry the demo adds itself: a link to the staff guide, drawn for staff only. After you edit a page under demo/docs/, run build_docs docs and reload, with no restart. Until a build exists, every address under /docs/ answers 404.

seed_demo made three accounts, all with the password password:

  • regular.user@example.com is signed in but not staff.
  • staff.user@example.com is staff.
  • super.user@example.com is a superuser, and staff too.

Only the staff and superuser accounts can open the staff guide at http://127.0.0.1:8000/staff-guide/. Everyone else is asked to sign in or is shown the forbidden page, and the sidebar has no entry for them. The command refuses to run unless DEBUG is on.

License

MIT. See LICENSE.

Metadata

Release files for django-mvp-sphinx 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-mvp-sphinx 0.1.0
File Size Uploaded
django_mvp_sphinx-0.1.0.tar.gz 60.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-mvp-sphinx 0.1.0
File Interpreter ABI Platform
django_mvp_sphinx-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 118.2 kB

Release files / django_mvp_sphinx-0.1.0.tar.gz

Download URL django_mvp_sphinx-0.1.0.tar.gz
Size 60.4 kB
Tags Source
SHA-256 checksum
How to use checksums
c99faaa0b768e0fb915b064453ca8827daad9fdd9d0b08f76619286d213a708c
BLAKE2b-256 checksum
How to use checksums
500ca8c8a5c8abc899d9ebe4cd14ae0096312ebf2e2ada62595e3b192a183630
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.

Transparency log

Release files / django_mvp_sphinx-0.1.0-py3-none-any.whl

Download URL django_mvp_sphinx-0.1.0-py3-none-any.whl
Size 57.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d2c9355b851f66274cfd00cc83854f83dc04af1688eafdce174948c73e4575b3
BLAKE2b-256 checksum
How to use checksums
85838d3c98375c1e59b3e0eb4fbd02bcc48484e7b995518aaa07148c81e5dd1e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page