Skip to content

Documentation

The goal

manimgx's docs are a website with four tabs: the User Guide (Welcome, which is the README, the Quickstart, chapters read in order, then the API reference), the Gallery, this Developer Guide, and the Changelog. Two things make them more than pages of text:

  • Every example shows what it makes. A code block that defines a scene appears with the film manimgx renders from it, rendered when the site is built, so no picture is ever older than the code beside it.
  • The reference tells a story, and misses nothing. Its pages are written by hand, in the order of a video's parts, and render the API from the docstrings; tests keep them whole: every name the package documents is on a page, once.

A website is HTML, CSS and JavaScript files, served by a host. Nobody wants to write those by hand for a docs site: the pages are Markdown, and a static site generator makes the files.

Zensical: the site generator

Zensical builds a site from Markdown. It is made by the team behind Material for MkDocs, with the same theme and Markdown extensions, and it supports some of MkDocs' plugins, awesome-nav and mkdocstrings among them. Its settings are docs/zensical.toml, whose paths are the docs folder's:

  • The site: its name, URL and repository, and where each page's "edit" link goes.
  • The theme: its features (the navbar's tabs, instant navigation, a copy button on code), its light and dark palettes, manimgx's own stylesheet and script, and docs/overrides/, where manimgx changes the theme's templates.
  • Markdown extensions: admonitions, content tabs, math (rendered by KaTeX), Mermaid diagrams, keyboard keys, and a formatter of manimgx's own for Python blocks (below).
  • Plugins: awesome-nav, which reads the navigation from the folders; mkdocstrings, which renders the API reference from the docstrings; and autorefs, which resolves links such as [Circle][manimgx.Circle].

zensical.toml has no nav: the navigation is the folders' (below).

The docs folder

The docs live in docs/, and the scripts that write what is generated in them in scripts/:

docs/
├── content/                  ← the site: every page, at the path its URL shows
│   ├── .nav.yml              ← the tabs, and the User Guide's reading order
│   ├── index.md              ← Welcome, the home page: the README, included
│   ├── user-guide/           ← the User Guide's chapters
│   ├── reference/            ← the API reference, by hand; its command-line page generated
│   ├── gallery/              ← the Gallery: its cards, and a page per film; generated
│   ├── developer-guide/      ← this guide, ordered by its own .nav.yml
│   ├── changelog.md
│   ├── films/                ← the examples' films: rendered, git-ignored
│   ├── showcase/             ← the README's wall and banner: made by scripts/showcase/
│   ├── stylesheets/, javascripts/, images/
│   └── _headers              ← the response headers Cloudflare sends
├── overrides/                ← where manimgx changes the theme: the header's logo and byline,
│                               the footer's band
├── templates/                ← how mkdocstrings shows an entry of the reference
├── zensical.toml             ← the site's settings, and llms.txt's primer for agents
├── site/                     ← the site, built: git-ignored
├── voice/                    ← what the narrated examples say: spoken once, committed
├── examples.py               ← renders the examples into content/films/, and shows each above
│                               its code while the site builds
├── svg.py                    ← a scene as an SVG that plays itself: the README's
├── links.py                  ← the README's URLs of the site as the site's paths
├── deprecated.py             ← leaves what is deprecated out of the API reference
├── films.py                  ← a picture named by its scene (a card's) shows its film's still
├── mkdocstrings.py           ← mkdocstrings finds templates/, from where the build runs
└── previews.py               ← a reference to the API previews its target, as a link does

scripts/
├── docs/
│   ├── gallery.py            ← writes the Gallery from examples/ into docs/content/gallery/
│   └── reference.py          ← writes the command line's reference, from its Typer app
└── showcase/
    ├── logo.py               ← draws the logo: the README's banner, the header's and its byline,
    │                           Academa's wordmark, the favicon
    └── wall.py               ← makes the README's wall from the example films' scenes

What the site's build loads (its formatter, its Markdown extensions and its griffe extension) is in docs/, beside the settings that name it; what writes pages and pictures before it is in scripts/.

The navigation is the folder tree of docs/content/. awesome-nav, which Zensical runs natively, reads the .nav.yml file of a folder for the order of its pages, and a page's title is its first heading (or the title of its front matter):

  • docs/content/.nav.yml makes the navbar's tabs, and gives the User Guide's reading order: Welcome, the Quickstart, the chapters, then the Reference.
  • developer-guide/.nav.yml orders this guide.
  • The reference's navigation is the folder tree that scripts/docs/reference.py writes (below): each level's own page comes first, as its Overview.

A section's own page (index.md) is a page like the others, listed first in its sidebar: Welcome, this guide's Setup, a reference level's Overview. navigation.indexes, which would make it the section's title instead and leave it out of the sidebar, is off.

Welcome: the home page

docs/content/index.md, the User Guide's first page, is the README: pymdownx.snippets includes it (--8<-- "README.md"), so the two can't drift. The README reaches the site's images and pages by absolute URLs, for GitHub and PyPI; on the site they become its own paths (docs/links.py), so just serve-docs shows the images it built. The README's picture of its scene ends in #readme: GitHub and PyPI show it, and the site, which shows the film itself above the code, hides it (stylesheets/manimgx.css).

The README

The README is the front page on GitHub and on PyPI, which render it themselves, so it is written in plain GitHub Markdown, with absolute links (PyPI resolves no relative link). Its images are the site's. GitHub and PyPI play no video, so what moves in them is an animated image: SVGs that play themselves where the picture is made of paths, sharp at any size and a fraction of a video's weight, and an AVIF where it is not. An SVG draws a surface's shape exactly (a circle in space is seen as an ellipse, a sphere's outline is one), but not its light, and a browser repaints an animated SVG at the screen's rate: six shaded films as SVGs cost Firefox two to three cores to play, the AVIF tiles at 25 frames a second 0.6 of one.

  • The banner (showcase/logo-dark.svg and logo-light.svg): the logo, which plays its opening once. The word ManimGX, typeset by manimgx's Typst (𝕄 as $bb(M)$, "anim" in New Computer Modern Bold, "GX" in Playwrite NO), is written in; then Manim's circle, square and triangle are drawn and each becomes a solid, a sphere, a cube and a pyramid, every frame a projection of their scene. scripts/showcase/logo.py draws it, the header's still logo (images/logo-*.svg) and the favicon; python -m scripts.showcase.logo draws them again.
  • The wall (showcase/<film>.avif and <film>@2x.avif): five seconds of six example films, three a row, each its own image, linked to its film. scripts/showcase/wall.py runs each film's scene and keeps its moment: on no background, so the page's own shows through, light or dark; without what the film fixes in the frame (its titles and readouts); as an animated AVIF with alpha at 25 frames a second, which GitHub and PyPI show; at a tile's size and twice it, which a <picture> picks by the screen's density (GitHub keeps it; PyPI shows the larger). A browser decodes an animated image on the CPU, frame after frame: at 60 frames a second the wall fell behind (Chromium showed some 50, for a core's work); at 25 every tile shows every frame. GitHub's image proxy served an image of 4.97 MB and refused one of 6.35 MB; the largest tile is 1.3 MB. The films take a minute to run, so the tiles are committed; python -m scripts.showcase.wall makes them again.
  • The chart (images/benchmark-light.svg and benchmark-dark.svg): the benchmark, as a race; scripts/benchmark/chart.py draws it from scripts/benchmark/results.json.
  • The scene's film (films/readme-<scene>.svg): docs/examples.py records the README's scene frame by frame with docs/svg.py, which reads the view and each path's paint at every frame and writes them as an SVG that plays itself (SMIL): a README scene is made of paths, whose shapes hold still.

Examples: films rendered from the code

docs/examples.py renders the examples before the site is built:

  1. It gathers the Python blocks (fenced as python or py) of the pages, of the README and of the docstrings in src/manimgx/, and keeps those that define a scene: a class whose base's name ends in Scene (the block's last, if it defines several).
  2. It renders each scene at 1280 × 720 and 60 frames per second, the films' own rate, in a process of its own (an example may change the configuration: a 9:16 one renders tall), into docs/content/films/: a still of its last frame with anything on it, and a video too if anything moves.
  3. A film is named after its scene and a digest of its code and of how films are made (FORMAT), so an example renders once per version of its code, and all of them again when the format changes. Only missing films are rendered, and films no example makes any more are deleted.

While the site builds, fence, the formatter that zensical.toml gives Python blocks, puts each block's film above its code. show="code" shows the code alone, and show="film" the film alone: two blocks with the same code let a page put words between a program and its video (the Basics shows a command there). On the page, a film plays while it is on screen, unless the reader prefers reduced motion.

So a scene's name is its film's, and must be unique across the docs; a failed example fails the build. A block meant to show code without a film defines no scene. Given paths, python -m docs.examples docs/content/user-guide renders only the examples written under them.

A narrated example (self.say(…)) says what docs/voice/ keeps, since the site is built with no voice's key: a new or changed line is spoken once, by a render with FAL_KEY, and its audio and words are committed with it. tests/docs/test_examples.py fails until they are.

A film is encoded for the web (x264's slow preset at CRF 28): a tenth of the size of a render's default (ultrafast, 18), alike to the eye, made in the same time.

scripts/docs/gallery.py writes the Gallery from examples/ alone, before the examples are rendered: a page of cards, one for each film, and a page for each film. What it says is in examples/README.md, which lists every example once:

  • The first page: the README's introduction (its first paragraph), then its groups, in their order, each a grid of cards. A card shows the film's still (![](film:HopfFibration), which docs/films.py resolves), its title and its first sentence. The whole card is a link to the film's page.
  • A film's page: its line in the README, - [The Hopf fibration](hopf_fibration.py): The 3-sphere is made of circles…, gives its title and its words, a sentence or two. Then the film, and its code, the file, folded under it: a block marked fold, whose film docs/examples.py renders as it renders every example's. The file's docstring tells the mathematics, for the reader who opens the code.
  • Its navigation: gallery/.nav.yml, written with the pages: the README's groups, so that a film's page lists every film beside it.

The pages are generated (content/gallery/, git-ignored), so an example added to examples/ and its README is in the Gallery at the next build. tests/docs/test_gallery.py checks that every example has its card and its page, and that a card says one short sentence.

The API reference

The problem: a reference generated from the package lists what the package has, module by module: it misses no name, but it tells no story. A reader finds every class and learns nothing of how the parts of a video fit together, and reads every name Manim CE kept, useful or not. A reference written by hand tells the story, but misses the names added after it, and keeps the names removed since.

The solution: the reference is written by hand, as a story, and renders the API from the package; tests keep the two together.

  • Its story: a video is a scene, which shows mobjects, which animations change and updaters keep, as the camera sees them, with sound, until rendering makes the video. Each section of docs/content/reference/ tells one part, in Learn's order: Scenes, Mobjects, Animations, Updaters, Camera and 3D, Sound, Rendering. A section opens with what its part is, then a card for each of its pages. A page is a topic, not a class: "Lines and arrows" tells Line, then Arrow, then Vector, then the tips, as one story.
  • Its API: a page writes its story in prose, and renders each object in it with mkdocstrings' manimgx.Circle block, from its docstring, in the order the story needs. A class's block shows its members, unless the page lists some, or none (members: false), to show them under headings of its own (Mobject's, on six pages). A member a public class takes from a private base (_Grid's, Matrix's and Table's) is listed in the block's inherited_members.
  • What it leaves out: a class, a function or a property decorated with @deprecated (PEP 702): a name kept only for code written for Manim CE, which ty flags where it is used. docs/deprecated.py removes each one as griffe loads the package, so it has no entry, its class does not list it, and a link to it does not resolve. A deprecated class stays in its module, under no other name, for the classes made from it to take their constructor from: no page shows it, nor what they take from it. The extension reads the decorator in the source, as ty does (a property's, on its getter). An attribute, a constant or a type alias can't carry the decorator: the few that no scene needs are listed in tests/docs/test_reference.py's UNDOCUMENTED.
  • What keeps it whole: tests/docs/test_reference.py finds what the package documents (every name it exports, and every documented member of an exported class) and what the pages render, and fails on a name no page shows, one shown twice, or one deprecated. The site's strict build fails on a block or a link whose object is gone. tests/docs/test_hidden.py type-checks every example the docs show, deprecated an error, so no page teaches a hidden name.
  • Cards: a section's page shows its pages as cards: a film's still, the page's name and a sentence, the whole card a link. A card names its film by its scene, ![](film:LinesAndArrows), which docs/films.py resolves to the film the examples' renderer made, whatever its digest. Each page opens with such a film, its code folded under it: a card's picture, and the page's.

The pages are rendered by mkdocstrings, which reads the docstrings with griffe. docs/templates/python/material/ shows an entry as a scene writes it:

  • Its film first: the docstring's example, its code folded under it, then what it is.
  • Its call, without types: m.Circle(radius=None, …), self.play(…), circle.surround(…), Circle.from_three_points(…), class MyScene(m.Scene):; an editor shows the types.
  • What it takes, in words: each parameter's name and description. Its **kwargs, a TypedDict, is opened by vocabulary: the keywords one class owns are listed on its entry ("A Matrix's keywords …", its docstring says) and linked from every other; the style, tip, animation and transform keywords are one link each, to where they are told.
  • Its source, one click away: a Source button that opens the code where it is defined.

A module's docstring is not shown: in manimgx it is a note for the developers who change the module. mkdocstrings reads the custom_templates setting from the working directory, and Zensical passes it on as written, not made absolute from the settings' folder as MkDocs does; docs/mkdocstrings.py makes it absolute before mkdocstrings reads it.

Instant previews

Rest the pointer on a link to a page of the site, and a preview of its target opens. A reference to the API previews too: its name, signature, bases and summary, as an editor's hover shows them. So the summary line of a docstring is also what its previews show.

  • Which links preview: Zensical's preview extension marks every link to a page of the site (targets.include = ["*"] in docs/zensical.toml), but not a heading's or a footnote. A reference to the API is an <autoref> tag until Zensical resolves it, after the extension has run. So docs/previews.py marks each of these tags, and the link keeps the mark. It also takes the mark off a card's link: the card shows its page's still, name and words already, and a preview would cover the cards beside it.
  • What a preview shows: the theme shows the target heading and the text after it, up to the next heading. stylesheets/manimgx.css limits the preview of an API object to its name, signature, bases and summary, and the preview of a module to its members' names and summaries. No icon marks a link that previews, because every link to the site does.
  • After a change to a Markdown extension in docs/: delete docs/.cache. Zensical's cache does not see a change in an extension's code, so a build reuses the pages it made before.

The command line's reference

scripts/docs/reference.py writes the command line's reference, reference/rendering/command-line.md, from the Typer app itself (typer manimgx.cli utils docs): the one page of the reference that is generated. It is always the app's, and it is not committed.

Docstrings

The reference is made from the docstrings, so they are written for readers of the docs:

  • They follow the Google style: a summary line, then Args:, Returns:, Raises: and Examples: sections.
  • An example is a Python block, under Examples:, that defines a scene. It is rendered like the pages' examples, so its scene's name is unique across the docs (TipableVMobjectAddTipExample, say).
  • A docstring links to another object as [text][manimgx.path.to.it], an autoref. The object must be on a page of the reference: the site's strict build fails on a link whose target no page shows. A class's keywords are linked by their class ([number line keywords][manimgx.NumberLine]), whose entry lists them.

Markdown for agents

An agent reads Markdown better than a page's HTML. Zensical's llmstxt plugin, set in [project.plugins.llmstxt] of docs/zensical.toml, writes three things:

  • Each page's Markdown, beside the page: /user-guide/quickstart/index.md beside /user-guide/quickstart/. The plugin converts the page as it is built, so the Markdown has what the page shows: the examples' code, the admonitions, the reference's signatures. A page's one action, Copy as Markdown (content.action.copy), copies it.
  • llms.txt, at the site's root, https://manimgx.academa.ai/llms.txt, which the README tells an agent to follow. First, a primer for an agent asked to make a video with manimgx: how to install it, a scene, check then render, and a cheat sheet of the API. Then a list of every page's Markdown, by section.
  • llms-full.txt: every page's Markdown, in one file.

The primer is the plugin's markdown_description, written by hand, so tests/docs/test_llms.py checks it against the API: its scene runs, and every name it teaches exists. The plugin writes the Markdown only of the pages its sections name, so the sections name every page with patterns (user-guide/*.md), and the test checks that each page matches one: a page in a new folder needs a pattern.

The agent skill

skills/manimgx/SKILL.md is manimgx's agent skill. npx skills add academa-labs/manimgx and gh skill install academa-labs/manimgx find it by its path, skills/<name>/SKILL.md, and copy its folder into an agent's skills, so the folder stays where it is. The installers read its front matter, so tests/docs/test_skill.py checks it against the specification: the name is its folder's, and the description is 1 to 1,024 characters.

The changelog

docs/content/changelog.md follows Keep a Changelog. The first release, 0.1.0, has no changes to list: its section says only that it is the first. After it, a change a user would notice adds a line under "Unreleased", in the same pull request. A release moves those lines into its version's section, which becomes the release's notes (see Project management).

Local preview

just serve-docs

Writes the Gallery, renders the missing films, writes the reference, then serves the site at http://localhost:8000 and rebuilds it as its pages change. An example that fails to render is reported and served without its film. A block added while it serves has no film until the examples are rendered again, and a name added to or removed from the package's exports shows in the reference once just serve-docs runs again: the reference's pages are written before it serves.

just build-docs

Writes the Gallery, renders the examples, writes the reference, and builds the site into docs/site/ with --strict: a warning, such as a link to a page or a name that does not exist, fails the build. This is what CI deploys.

Zensical runs as python -m zensical, from the repository's root, which it puts on the path, so it can import docs.examples.fence; --config-file docs/zensical.toml gives it the site.

Deployment

The site is static files, served by Cloudflare Workers as static assets: no Worker script runs, and every request is answered from the files in site/. .github/deploy/docs.jsonc configures it:

  • The Worker, manimgx-docs, serves the site at its domain, manimgx.academa.ai.
  • A page is a folder (changelog/index.html): a request for /changelog is redirected to /changelog/.
  • A path that matches nothing gets the nearest 404.html, with status 404.
  • docs/content/_headers is copied to the site's root, where Cloudflare reads it (it is never served): security headers for every page, long caching for the theme's bundles (their names change with their content), and noindex on workers.dev hosts.

Academa's internal/manimgx-hosting Terraform unit provisions the Worker identity, custom domain and deployment credential. It also provisions the coverage report's Worker and domain. Wrangler publishes assets and previews; it leaves domain ownership to Terraform. The account ID is a GitHub repository variable, and each site's deployment token is installed in its production and preview environments by Terraform.

The workflow deploy-docs.yaml builds the site on every push and pull request, deploys it from main, and previews each pull request from a branch of the repository at its own address, pr-<number>-manimgx-docs.<account subdomain>.workers.dev. See GitHub workflows.

Learn more