A project to copy
A documentation site is a directory of documents, one settings file and one workflow. Both files are printed here in full — take them, point the render step at your own documents, and what a landing page and a manual page need is the rest of this page. Search, both modes, the bar and the footer arrive with the theme and are asked for nowhere.
The commands in Publishing it, in CI are the ones this site is rendered with —
PHP, Composer, Node, no make and no container — and 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 renderer is built by the workflow, 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 is what makes a theme called soul exist. Written empty,
the bar carries the project title and the footer carries the site's own
sections. Configuration has each setting and what happens when it is
left out.
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 thing allowed to deploy.
#
# Nothing here is specific to any 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 at all.
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@v4
- 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 is named rather than inherited.
- uses: actions/setup-node@v4
with:
node-version: '24'
# The renderer, built where it is used and thrown away with the runner.
# A documentation project is documents; requiring the theme into the
# repository itself would put a `composer.json` and a lock file next to
# them that nothing else ever 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:dev-main
# `--fail-on-error` turns a reference the renderer could not resolve 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 switched to
# the branch-based build runs Jekyll, which drops every path starting
# with an underscore — including `_search.json` and `_images/`.
- run: touch site/.nojekyll
- uses: actions/upload-pages-artifact@v3
with:
path: site
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@v5
- id: deploy
uses: actions/deploy-pages@v4
# 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 thing allowed to deploy.
#
# Nothing here is specific to any 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 at all.
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@v4
- 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 is named rather than inherited.
- uses: actions/setup-node@v4
with:
node-version: '24'
# The renderer, built where it is used and thrown away with the runner.
# A documentation project is documents; requiring the theme into the
# repository itself would put a `composer.json` and a lock file next to
# them that nothing else ever 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:dev-main
# `--fail-on-error` turns a reference the renderer could not resolve 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 switched to
# the branch-based build runs Jekyll, which drops every path starting
# with an underscore — including `_search.json` and `_images/`.
- run: touch site/.nojekyll
- uses: actions/upload-pages-artifact@v3
with:
path: site
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@v5
- id: deploy
uses: actions/deploy-pages@v4
The renderer is built in the job that uses it and goes away with the runner — a handful of Composer lines, no manifest in the repository. Publishing it, in CI reads the rest of this file, including 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 what each shape 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.: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. It is what the rail, the breadcrumb and the footer columns are built from, and a landing page that lists its sections in prose but writes no tree is a site whose every other page has nothing to navigate with.
The rest of the shape follows from that opening: the hero makes the claim, the
row of presses is the way on, and everything after a band belongs to it
until the next one starts. Directives has each of them with its options
and a rendered example.
Running it#
composer require typo3/soul-guides-theme:dev-main # 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:dev-main # 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 is what turns them into a site. Installation says what that directory of its own is for, and 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.