A project to copy
A documentation site is a directory of documents, one settings file and one workflow. Both files stand here in full. Take them and point the render step at your own documents. The rest of this page is what a landing page and a manual page need. Search, both modes, the bar and the footer arrive with the theme.
The commands in Publishing it, in CI are the ones this site renders with: PHP,
Composer, Node, no make and no container. The step after the render is
the same file in both cases, out of the package.
What a project holds#
.github/workflows/publish.yml the renderer, render, finish, publish
docs/
guides.xml the project, the bar, the footer
index.rst the landing page
guide/ the manual pages.github/workflows/publish.yml the renderer, render, finish, publish
docs/
guides.xml the project, the bar, the footer
index.rst the landing page
guide/ the manual pages
No manifest among them. The workflow builds the renderer in a directory the runner discards. Both files are below.
The configuration#
<!--
The smallest file that renders with this theme, plus the bar and the
footer, which are configuration because a page free to write its own is a
site that disagrees with itself by the third page.
The <extension> element is load-bearing: it is what makes a theme called
"soul" exist, and without it the render stops on `Theme "soul" is not
registered`. It also registers the syntax highlighter and the Markdown
parser the theme is published with, so neither is named here.
-->
<guides xmlns="https://www.phpdoc.org/guides"
input-format="rst"
links_are_relative="true"
theme="soul"
default_code_language="text">
<project title="Example Project" version="1.0" copyright="© 2026 Example Project"/>
<extension class="TYPO3\Soul\GuidesTheme\DependencyInjection\SoulExtension">
<brand>Example</brand>
<product>Documentation</product>
<!-- The handful of places this site has. Not the toctree: that is the
rail's job. An href here is a document, written the way a :doc:
reference is, and resolved per page. -->
<navigation>
<link href="/guide/index" label="Guide"/>
</navigation>
<!-- The columns are the toctree itself. What is configured is only
what the tree cannot know. -->
<footer>
<note>An example, not a product.</note>
</footer>
</extension>
</guides>
<?xml version="1.0" encoding="UTF-8" ?>
<!--
The smallest file that renders with this theme, plus the bar and the
footer, which are configuration because a page free to write its own is a
site that disagrees with itself by the third page.
The <extension> element is load-bearing: it is what makes a theme called
"soul" exist, and without it the render stops on `Theme "soul" is not
registered`. It also registers the syntax highlighter and the Markdown
parser the theme is published with, so neither is named here.
-->
<guides xmlns="https://www.phpdoc.org/guides"
input-format="rst"
links_are_relative="true"
theme="soul"
default_code_language="text">
<project title="Example Project" version="1.0" copyright="© 2026 Example Project"/>
<extension class="TYPO3\Soul\GuidesTheme\DependencyInjection\SoulExtension">
<brand>Example</brand>
<product>Documentation</product>
<!-- The handful of places this site has. Not the toctree: that is the
rail's job. An href here is a document, written the way a :doc:
reference is, and resolved per page. -->
<navigation>
<link href="/guide/index" label="Guide"/>
</navigation>
<!-- The columns are the toctree itself. What is configured is only
what the tree cannot know. -->
<footer>
<note>An example, not a product.</note>
</footer>
</extension>
</guides>
Everything inside the theme's <extension> is optional. The element
itself is not, because it makes a theme called soul exist. Empty, the
bar carries the project title and the footer carries the site's own
sections. Configuration has each setting and what happens without
it.
The workflow#
# Render the documentation and publish it to GitHub Pages.
#
# Two jobs, because they need different permissions. The first turns documents
# into a site and uploads it. The second is the only one that can deploy.
#
# Nothing here is specific to one project. Point the render step at your own
# documentation directory, and this file is the whole build. The repository it
# runs on holds documents and this file, and no PHP manifest.
name: Documentation
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
# A second push to a branch replaces the first. Deployments queue instead.
# A half-replaced site is worse than a site one commit behind.
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true
jobs:
render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
# The finishing step is one bundled file with no dependencies, but it is
# Node, and the version has a name instead of an inheritance.
- uses: actions/setup-node@v7
with:
node-version: '24'
# The renderer, built where it is in use and thrown away with the runner.
# A documentation project is documents. The theme in the repository
# itself puts a `composer.json` and a lock file next to them that nothing
# else reads. The theme brings the renderer with it, so this one
# `require` is the whole dependency.
- name: The renderer
env:
RENDERER: ${{ runner.temp }}/renderer
run: |
mkdir -p "$RENDERER"
cd "$RENDERER"
composer init --no-interaction --name=example/documentation
composer require --no-interaction --no-progress typo3/soul-guides-theme
# `--fail-on-error` turns a reference with no target into a red build.
# Without it a broken link is a line in a log nobody reads.
- name: Render the documents
run: ${{ runner.temp }}/renderer/vendor/bin/guides docs --output=site -c docs --fail-on-error
# Everything between a render and a site: the drop-in copied to the site
# root, every element drawn so the pages read with no script, the search
# index written, and a refusal to publish a reference that leaves the
# output. The file sits inside the theme package and copies what is
# beside it, so the `require` above brought it.
- name: Finish the site
run: node ${{ runner.temp }}/renderer/vendor/typo3/soul-guides-theme/resources/dist/soul-finish.js site
# Pages serves this artifact as it stands, but a repository on the
# branch-based build runs Jekyll, which drops every path with a leading
# underscore, `_search.json` and `_images/` included.
- run: touch site/.nojekyll
# The upload leaves a dotfile out unless told otherwise, and the marker
# above is one.
- uses: actions/upload-pages-artifact@v5
with:
path: site
include-hidden-files: true
publish:
needs: render
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
concurrency:
group: pages
cancel-in-progress: false
steps:
- uses: actions/configure-pages@v6
- id: deploy
uses: actions/deploy-pages@v5
# Render the documentation and publish it to GitHub Pages.
#
# Two jobs, because they need different permissions. The first turns documents
# into a site and uploads it. The second is the only one that can deploy.
#
# Nothing here is specific to one project. Point the render step at your own
# documentation directory, and this file is the whole build. The repository it
# runs on holds documents and this file, and no PHP manifest.
name: Documentation
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
# A second push to a branch replaces the first. Deployments queue instead.
# A half-replaced site is worse than a site one commit behind.
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true
jobs:
render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
# The finishing step is one bundled file with no dependencies, but it is
# Node, and the version has a name instead of an inheritance.
- uses: actions/setup-node@v7
with:
node-version: '24'
# The renderer, built where it is in use and thrown away with the runner.
# A documentation project is documents. The theme in the repository
# itself puts a `composer.json` and a lock file next to them that nothing
# else reads. The theme brings the renderer with it, so this one
# `require` is the whole dependency.
- name: The renderer
env:
RENDERER: ${{ runner.temp }}/renderer
run: |
mkdir -p "$RENDERER"
cd "$RENDERER"
composer init --no-interaction --name=example/documentation
composer require --no-interaction --no-progress typo3/soul-guides-theme
# `--fail-on-error` turns a reference with no target into a red build.
# Without it a broken link is a line in a log nobody reads.
- name: Render the documents
run: ${{ runner.temp }}/renderer/vendor/bin/guides docs --output=site -c docs --fail-on-error
# Everything between a render and a site: the drop-in copied to the site
# root, every element drawn so the pages read with no script, the search
# index written, and a refusal to publish a reference that leaves the
# output. The file sits inside the theme package and copies what is
# beside it, so the `require` above brought it.
- name: Finish the site
run: node ${{ runner.temp }}/renderer/vendor/typo3/soul-guides-theme/resources/dist/soul-finish.js site
# Pages serves this artifact as it stands, but a repository on the
# branch-based build runs Jekyll, which drops every path with a leading
# underscore, `_search.json` and `_images/` included.
- run: touch site/.nojekyll
# The upload leaves a dotfile out unless told otherwise, and the marker
# above is one.
- uses: actions/upload-pages-artifact@v5
with:
path: site
include-hidden-files: true
publish:
needs: render
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
concurrency:
group: pages
cancel-in-progress: false
steps:
- uses: actions/configure-pages@v6
- id: deploy
uses: actions/deploy-pages@v5
The job that uses the renderer builds it, and it goes away with the runner: a few Composer lines, no manifest in the repository. Publishing it, in CI reads the rest of this file, and what it refuses to publish.
The two page shapes#
The landing page writes :layout: marketing at the top and is a run
of full-bleed bands with no rail. Every other page writes no such field
and is the manual shape, a column beside a rail. Both carry the same bar and
the same footer, because a reader must never have to work out which site
they are on. Directives has the parts of each shape.
:navigation-title: Home
:layout: marketing
===============
Example Project
===============
.. toctree::
:titlesonly:
:hidden:
guide/index
.. hero:: /_images/workbench.png
What this project is, in the sentence somebody arriving needs.
.. button-bar::
.. button:: :doc:`guide/index`
:icon: actions-download
.. button:: The source
:href: https://github.com/example/project
:variant: secondary
:rel: external
.. band:: What it holds
:quiet:
.. grid::
.. card:: Write a page
:href: /guide/index
:tag: Guide
What a manual page is made of.:navigation-title: Home
:layout: marketing
===============
Example Project
===============
.. toctree::
:titlesonly:
:hidden:
guide/index
.. hero:: /_images/workbench.png
What this project is, in the sentence somebody arriving needs.
.. button-bar::
.. button:: :doc:`guide/index`
:icon: actions-download
.. button:: The source
:href: https://github.com/example/project
:variant: secondary
:rel: external
.. band:: What it holds
:quiet:
.. grid::
.. card:: Write a page
:href: /guide/index
:tag: Guide
What a manual page is made of.
The hidden toctree is not a formality. The rail, the breadcrumb and the footer columns come from it. A landing page that lists its sections in prose and writes no tree is a site whose every other page has no navigation.
The rest of the shape follows from that opening. The hero makes the claim,
and the row of presses is the way on. Everything after a band belongs to
it until the next one starts. Directives has each of them with its
options and a rendered example.
Run it#
composer require typo3/soul-guides-theme # in a directory of its own
vendor/bin/guides docs --output=site -c docs --fail-on-error
node vendor/typo3/soul-guides-theme/resources/dist/soul-finish.js site
php -S localhost:8000 -t sitecomposer require typo3/soul-guides-theme # in a directory of its own
vendor/bin/guides docs --output=site -c docs --fail-on-error
node vendor/typo3/soul-guides-theme/resources/dist/soul-finish.js site
php -S localhost:8000 -t site
The second command writes documents. The third turns them into a site. Installation says what that directory of its own is for. Publishing it, in CI says what the workflow's jobs are for and what they refuse to publish.
Core markup, and what it becomes is the theme's answer to what the renderer already emits: admonitions, code, tabs, tables and a reference entry, with the markup that produces each one beside it.
Core markup, and what it becomes is the theme's answer to what the renderer already emits: admonitions, code, tabs, tables and a reference entry, with the markup that produces each one beside it.