How to add and edit pages on the PwnzzAI documentation site.
The site is built with MkDocs using the
Material theme, and published to
GitHub Pages at https://owasp.org/PwnzzAI/. Everything
it serves comes from the docs/ directory plus the mkdocs.yml config at the repo
root.
- Quick version
- Layout
- Step 1 — Create the page
- Step 2 — Add it to the navigation
- Navigation hierarchy
- Step 3 — Preview locally
- Step 4 — Commit and deploy
- Writing pages
- Lab page template
- Conventions
- Pre-submit checklist
- Troubleshooting
# 1. write the page
$EDITOR docs/my-new-page.md
# 2. add it to nav: in mkdocs.yml
# - My New Page: my-new-page.md
# 3. preview
pip install mkdocs mkdocs-material
mkdocs serve # http://127.0.0.1:8000
# 4. ship
git add docs/my-new-page.md mkdocs.yml
git commit -m "docs: add my new page"
git pushPushing to main triggers the GitHub Pages deploy automatically. No manual build
step, and no committing the site/ directory.
mkdocs.yml # site config + navigation tree
docs/
├── index.md # site home page
├── installation.md
├── dev-guide.md
├── labs.md # the lab manual
├── misinformation.md # per-lab deep dive
├── workshop-cloud-llm-setup.md
├── CHALLENGE_SOLUTIONS.md # answer key (intentionally not in nav)
├── rfc-ex-b-direct-prompt-injection.md
└── assets/
└── images/
├── logo.png
├── favicon.png
└── labs/ # lab screenshots (image1.png … image18.png)
Two rules follow from this:
- Only files under
docs/are published. A markdown file elsewhere in the repo (like this one) is not part of the site. - Paths in
mkdocs.ymlare relative todocs/, not to the repo root. It'slabs.md, neverdocs/labs.md.
Add a .md file under docs/. Filename becomes the URL, so use lowercase
kebab-case:
| File | URL |
|---|---|
docs/troubleshooting.md |
/troubleshooting/ |
docs/labs/sql-injection.md |
/labs/sql-injection/ |
docs/My Page.md |
avoid — spaces and capitals make ugly, fragile URLs |
Start the file with a single # H1 — that becomes the page title in the browser tab
and the top of the sidebar's table of contents. Use ## for sections below it; don't
use a second #.
# Page Title
Intro paragraph.
## First SectionSubdirectories are fine and are the right call once a topic has several pages — they group the URLs as well as the files.
Edit the nav: block at the bottom of mkdocs.yml. Left of the colon is the label
shown in the UI; right of it is the path relative to docs/:
nav:
- Home: index.md
- Installation: installation.md
- Dev Guide: dev-guide.md
- Labs:
- Lab Manual: labs.md
- Misinformation: misinformation.md
- Troubleshooting: troubleshooting.md!!! note "Files not listed in nav still get published"
MkDocs builds every .md file under docs/ whether or not it appears in nav:.
An omitted page has no sidebar entry but is still reachable by URL and still
turns up in site search. That's deliberate for CHALLENGE_SOLUTIONS.md — it's
the answer key, kept out of the menu. If you're adding a page you want people to
find, it must be in nav:.
Indentation in nav: is the whole hierarchy — there is no other mechanism. A key: value pair is a page; a key whose value is a list is a section:
nav:
- Installation: installation.md # page
- Labs: # section (value is a list)
- Lab Manual: labs.md
- Misinformation: misinformation.mdThe rendering depends on which Material features are switched on, and this site
enables navigation.tabs, navigation.sections, and navigation.expand
(mkdocs.yml:23-25). With that combination:
| Nesting level | Renders as |
|---|---|
| Level 1 (top-level) | A tab in the header bar — whether it's a page or a section |
| Level 2 | A section group in the left sidebar: a bold, non-clickable header with its pages beneath |
| Level 3+ | Pages / nested groups inside that group, expanded by default rather than collapsed |
So the current config produces four tabs — Home, Installation, Dev Guide, Labs — and clicking Labs shows a sidebar containing Lab Manual and Misinformation.
Header tabs: Home Installation Dev Guide [Labs]
│
Sidebar (Labs tab): ▼
Lab Manual
Misinformation
Because navigation.expand is on, sub-sections don't collapse — everything in the
active tab is visible at once. That's fine at the current size, but it means a deep
tree produces a very long sidebar; keep it in mind before nesting four levels of labs.
- New top-level tab — only for a genuinely new top-level topic. Tabs are horizontal and finite; past six or seven they wrap and stop being scannable.
- Nested under an existing section — the default choice. A new per-lab deep dive goes under
Labs, not next to it:
- Labs:
- Lab Manual: labs.md
- Misinformation: misinformation.md
- Excessive Agency: excessive-agency.md # new- A section inside a section — reach for this when one grouping genuinely has sub-groupings:
- Labs:
- Lab Manual: labs.md
- Prompt Injection:
- Direct: labs/direct-prompt-injection.md
- Indirect: labs/indirect-prompt-injection.md
- Guardrail Ladder: labs/guardrail-ladder.md
- Data Attacks:
- Data Poisoning: labs/data-poisoning.md
- RAG Poisoning: labs/rag-poisoning.md
- Misinformation: misinformation.mdThat's three levels, which is about the practical ceiling here — deeper works in
MkDocs but reads badly under navigation.expand.
nav: order is literal and manual — top to bottom, exactly as written. MkDocs never
sorts alphabetically or by date, and filenames have no influence. Order pages the way
a reader progresses: install → concepts → labs → reference.
Omitting nav: entirely would make MkDocs auto-build the tree alphabetically from
the filesystem, which is why this repo declares it explicitly.
A section is a label, not a destination. Labs: has no page of its own, which is why
labs.md sits inside the section as "Lab Manual" rather than being the section
itself. Clicking the section header in the sidebar does nothing.
Making a section's landing page clickable requires the navigation.indexes feature,
which this site does not currently enable:
# mkdocs.yml — would need this under theme.features
- navigation.indexes# then the first child becomes the section's own page
- Labs:
- labs.md # ← no label: this IS the Labs page
- Misinformation: misinformation.mdEnabling it is a site-wide change affecting every section, so raise it in a PR of its own rather than as a side effect of adding a page.
These are independent. A file can live at docs/misinformation.md and appear
nested under Labs in the menu; nothing forces the two to agree.
But nav nesting doesn't change URLs — only directories do. docs/misinformation.md
stays at /misinformation/ no matter how deeply you nest its menu entry. So once a
section has more than two or three pages, move the files into a matching
subdirectory:
docs/labs/direct-prompt-injection.md → /labs/direct-prompt-injection/
The URL then reflects the hierarchy the reader sees, and related files sit together.
Remember to update the nav: paths and any inbound links when you move a file.
pip install mkdocs mkdocs-material
mkdocs serveThen open http://127.0.0.1:8000. The dev server live-reloads on save, so keep it running while you write.
Note that mkdocs is not in requirements.txt — it isn't needed to run the app,
so install it separately (a virtualenv or pipx is fine). CI installs it the same
way.
Two commands worth knowing:
mkdocs build # render to ./site/ — same as CI does
mkdocs build --strict # turn warnings (broken links, orphan pages) into errorsRun --strict before opening a PR — it's the fastest way to catch a typo'd internal
link. Separately, watch the build log for the INFO line listing pages that exist
but aren't in nav:; that one is informational and --strict won't fail on it.
.github/workflows/docs.yml handles publishing:
| Event | What happens |
|---|---|
PR touching docs/** or mkdocs.yml |
Builds the site — a build failure fails the check |
Push to main |
Builds and deploys to GitHub Pages |
Push to feature/** |
Builds only; no deploy |
The workflow only fires on changes to docs/**, mkdocs.yml, or the workflow file
itself. A docs change outside those paths won't trigger a rebuild.
Do not commit the site/ directory — it's generated output, built fresh by CI on
every deploy.
Put images under docs/assets/images/, using the labs/ subfolder for lab
screenshots. Reference them with a path relative to docs/:

*Figure 1 — description of what the screenshot shows*The italic caption line under the image is the existing convention in labs.md;
follow it so figures read consistently. Prefer PNG for screenshots, and crop to the
relevant part of the UI rather than pasting a full desktop.
Link to other pages by their .md filename, not their URL. MkDocs rewrites the
extension at build time and — importantly — warns you if the target doesn't exist:
[Installation guide](installation.md) ← correct
[Installation guide](/installation/) ← works, but silently breaks on renameSection anchors are auto-generated from headings (lowercased, spaces to hyphens):
See [RAG Poisoning](labs.md#rag-poisoning) for the setup.Because attr_list is enabled, you can also pin a stable anchor to a heading so it
survives the heading text being reworded — labs.md uses this:
## Model Theft Attack {#model-theft-attack}These extensions are configured in mkdocs.yml, so you can use them without any
further setup:
Admonitions (admonition, pymdownx.details) — call-out boxes. Types include
note, tip, warning, danger, info, example:
!!! warning "Run this in an isolated environment"
PwnzzAI is intentionally vulnerable.
??? note "Collapsed by default"
Use `???` instead of `!!!` for a collapsible block.Code blocks with copy buttons and line numbers (pymdownx.highlight,
content.code.copy):
```python title="application/route.py" hl_lines="2"
def misinformation():
return render_template('misinformation.html')
```Tabbed content (pymdownx.tabbed) — good for local-vs-cloud or
Docker-vs-manual instructions:
=== "Ollama (local)"
Run `make dev`.
=== "Cloud model"
Save an API key first.Tables (tables) — standard GitHub-flavoured pipe tables.
Anchor permalinks (toc with permalink: true) — every heading gets a ¶
link, so readers can deep-link a section.
Also available: pymdownx.superfences (nested/complex fences), pymdownx.inlinehilite
(`#!python code`), pymdownx.snippets (include external files), and
md_in_html (markdown inside HTML blocks).
docs/misinformation.md is the reference for a per-lab deep dive. The structure that
page follows, and that new lab pages should reuse:
# Lab Name
**Lab N** — [LLMxx:2025 Category](https://genai.owasp.org/llmrisk/...)
[http://localhost:8080/route](http://localhost:8080/route)
| | |
| :---- | :---- |
| **Objective** | What the attacker is trying to achieve |
| **Threat Model** | STRIDE-style framing |
| **Difficulty Rating** | Beginner / Intermediate / Advanced |
| **OWASP Category** | LLMxx:2025 ... |
| **Backends** | Local (Ollama) and/or cloud |

## What the vulnerability is ← concept, provider-neutral
## How this lab is built ← pipeline + the specific design flaws, with file:line refs
## Walkthrough ← numbered, reproducible steps
## Detection ← signals you could assert on in a test
## Mitigations ← ordered by impact
## API reference ← routes, request/response shapes, status codes
## Source map ← file → role table
## Related labs ← cross-linksFor a short entry that only needs the summary block, add it to docs/labs.md
instead of creating a new file — the lab manual is the index, and per-page deep dives
are for labs that need the extra depth.
- Filenames: lowercase kebab-case (
workshop-cloud-llm-setup.md). The uppercase exceptions indocs/(CHALLENGE_SOLUTIONS.md) are legacy; don't add new ones. - One H1 per page, at the top.
- Code references use
file.py:line(application/route.py:1253) so they're greppable and clickable in editors. - Ports and URLs: the app runs on
localhost:8080throughout the docs — keep it consistent. - OWASP links point at the 2025 Top 10 for LLM Applications entries; match the format used in
labs.md. - Don't publish the answer key. New solution content belongs in
docs/CHALLENGE_SOLUTIONS.md, which stays out ofnav:. - Security framing: PwnzzAI is deliberately vulnerable. Pages describing an exploit should also cover the mitigation — that pairing is the teaching point.
- File is under
docs/with a lowercase kebab-case name - Single
#H1 at the top - Added to
nav:inmkdocs.yml(or deliberately omitted, and you know why) -
mkdocs build --strictpasses with no warnings - Internal links use
.mdfilenames and resolve - Images render, live under
docs/assets/images/, and have captions - Checked in both light and dark mode (the theme has a toggle)
-
site/is not staged
Page doesn't appear in the menu — it's missing from nav:, or the path is wrong.
Paths are relative to docs/: my-page.md, not docs/my-page.md.
INFO - The following pages exist in the docs directory, but are not included in the "nav" configuration
— exactly the above. It's informational rather than an error, and --strict won't
fail the build over it, so read the log rather than relying on the exit code.
Broken image — check the path is relative to docs/ and the case matches. Linux
CI is case-sensitive even when macOS and Windows let it slide locally, so
Image15.PNG will build fine on your machine and 404 in production.
Link works locally, 404s on the deployed site — you used an absolute path like
/installation/. The site is served from a subpath (/PwnzzAI/), so absolute paths
break. Use relative .md links.
Anchor link doesn't jump — anchors are derived from heading text; if the heading
changed, the anchor did too. Pin it with {#stable-anchor}.
Changes not live after pushing — confirm the push landed on main (deploy is
gated on it) and that the change touched docs/** or mkdocs.yml. Check the
Deploy MkDocs to GitHub Pages run in the Actions tab.
mkdocs: command not found — pip install mkdocs mkdocs-material. It's not in
requirements.txt by design.
Admonition renders as plain text — the content must be indented four spaces
under the !!! line, and there must be a blank line after it.
Related: CONTRIBUTING.md for the general contribution workflow, and docs/dev-guide.md for working on the application itself.