Writing Documentation¶
Everything on this site is a Markdown file in the docs/ directory of the main repository. There is no CMS and no separate docs repo — a documentation change is an ordinary pull request against 2.x.
The two-minute path¶
Every page has a edit icon in its top-right corner. Click it and GitHub opens that exact Markdown file in its web editor. Make your change, click Commit changes, and GitHub walks you through forking the repo and opening a pull request. You never leave the browser and you never install anything.
Use this for typos, clarifications, a missing option, a wrong default. It is the intended path for most documentation changes.
Previewing locally¶
Worth the setup if you are adding pages, moving things around, or touching anything with screenshots.
# One-time setup
python3 -m venv .venv-docs
source .venv-docs/bin/activate
pip install -r docs/requirements.txt
# Live-reloading preview on http://127.0.0.1:8000
mkdocs serve
mkdocs serve rebuilds and refreshes the browser every time you save. To reproduce exactly what CI does — including turning every warning into an error:
Run --strict before you push
CI runs mkdocs build --strict, which fails the build on a broken internal link, a link to a page that is not in the nav, or a missing snippet file. Catching that locally is faster than waiting for CircleCI.
Where things live¶
docs/
├── index.md Home page
├── getting-started/ Install → first blocked request
├── configuration/ Every YAML key, one page per section
├── plugins/ One page per plugin
├── presets/ The shipped rule sets
├── guides/ Task-oriented walkthroughs
├── reference/ Lookup tables
├── contributing/ Process (this section)
├── requirements.txt Pinned docs toolchain
└── assets/
├── images/ Screenshots and diagrams
└── stylesheets/extra.css Small style overrides
The site structure is not inferred from the directory tree — it comes from the nav: block in mkdocs.yml at the repo root. A new page must be added to nav: or the build fails.
nav:
- Plugins:
- plugins/index.md
- IP Address: plugins/ip-address.md
- My New Plugin: plugins/my-new-plugin.md # <- add your page here
An entry written as a bare path (plugins/index.md) takes its title from the page's # heading. An entry written as Label: path uses the label in the sidebar. Prefer the bare path unless the heading is too long for a sidebar.
Page conventions¶
- One
#heading per page, and it comes first. Everything else is##or deeper. Skipping a level (##straight to####) breaks the table of contents. - Sentence case for headings, except where a proper noun or a config key demands otherwise.
- Config keys, class names, file paths, and CLI flags go in backticks.
- Link to other pages with relative Markdown paths, including the
.mdextension:[Rate Limit](../plugins/rate-limit.md). MkDocs rewrites those to the right URLs and--strictverifies every one of them. Never hand-write a site-absolute URL. - Prefer a table over a bulleted list when every item shares the same fields — options, exceptions, modes, and return values all read better as tables.
- Say what the default is. Every documented option should state its default value and what happens when it is omitted.
Code snippets¶
Always tag the language. It drives both syntax highlighting and the copy button.
Tabs for per-platform variants¶
Use tabbed blocks when the same task differs by framework. Tabs with matching labels stay in sync across the whole site — a reader who picks "WordPress" once sees WordPress everywhere.
=== "Drupal"
```php
// settings.php
Firewall::create([__DIR__ . '/firewall.yml'])->evaluate();
```
=== "WordPress"
```php
// wp-config.php
Firewall::create([__DIR__ . '/firewall.yml'])->evaluate();
```
Annotations¶
Numbered callouts attach prose to a specific line without cluttering the code. Add ! after the language to keep the annotation markers out of the copy buffer.
```yaml
global:
mode: block # (1)!
require_config: true
```
1. `log` evaluates plugins but never terminates the request. Use it to audit
a rule set before enforcing it.
Embedding real files¶
The best way to keep an example honest is to not copy it. --8<-- pulls the actual shipped file into the page at build time, so it cannot drift:
```yaml title="presets/wordpress.yml"
# WordPress Endpoint Blocking Configuration
#
# Blocks common WordPress admin pages, sensitive files, and attack vectors
# while allowing normal website functionality.
plugins:
- plugin: "Kanopi\\Firewall\\Plugins\\Url"
response: block
weight: -100
enable: true
config:
# =====================================================================
# WordPress Admin Access
# Block all wp-admin and wp-login access
# =====================================================================
- path@starts_with:/wp-admin
- path@starts_with:/wp-login
- path:/wp-login.php
# =====================================================================
# XML-RPC Endpoint
# Frequently abused for DDoS and brute force attacks
# =====================================================================
- path:/xmlrpc.php
# =====================================================================
# WordPress REST API
# Block all REST API access (uncomment if you need to block it)
# =====================================================================
- path@starts_with:/wp-json/
- path:/wp-json
# =====================================================================
# WordPress Configuration Files
# Prevent direct access to sensitive configuration files
# =====================================================================
- path@contains:/wp-config
- path:/readme.html
- path:/license.txt
# =====================================================================
# WordPress Core PHP Files
# Block direct access to WordPress core files
# =====================================================================
- path:/wp-activate.php
- path:/wp-blog-header.php
- path:/wp-comments-post.php
- path:/wp-cron.php
- path:/wp-links-opml.php
- path:/wp-load.php
- path:/wp-mail.php
- path:/wp-settings.php
- path:/wp-signup.php
- path:/wp-trackback.php
# =====================================================================
# WordPress Includes Directory
# Block direct access to wp-includes PHP files
# =====================================================================
- type: AND
rules:
- path@starts_with:/wp-includes/
- path@ends_with:.php
# =====================================================================
# Uploaded File PHP Execution
# Prevent PHP execution in uploads directory
# =====================================================================
- type: AND
rules:
- path@starts_with:/wp-content/uploads/
- path@ends_with:.php
# =====================================================================
# Debug and Error Logs
# Prevent access to log files
# =====================================================================
- path@ends_with:/debug.log
- path@ends_with:/error_log
- path@contains:/error_log
# =====================================================================
# Common Attack Patterns in URLs
# Block obvious malicious patterns
# =====================================================================
- path@contains:../
- path@contains:..%2F
- path@contains:.env
- path@contains:/phpMyAdmin
- path@contains:/phpmyadmin
# =====================================================================
# SQL Injection and Code Execution in Query Strings
# Block common attack patterns in URL parameters
# =====================================================================
- query@contains:UNION
- query@contains:SELECT
- query@contains:base64_decode
- query@contains:eval(
- query@contains:system(
- query@contains:exec(
- query@contains:shell_exec
# =====================================================================
# WordPress Vulnerability Scanners
# Block known WordPress vulnerability scanners by User-Agent
# =====================================================================
- header.user-agent@contains:WPScan
- header.user-agent@regex:/wp[_-]?(scan|vuln|exploit)/i
# =====================================================================
# Optional: Bypass Rules
# =====================================================================
# Uncomment and configure if you need to allow specific IPs to access
# WordPress admin or other blocked endpoints
#
# plugins:
# - plugin: "Kanopi\\Firewall\\Plugins\\IpAddress"
# response: allow
# weight: -200
# enable: true
# config:
# # Allow office IP
# - 192.168.1.100/32
# # Allow VPN subnet
# - 10.0.0.0/8
```
Paths are relative to the repository root. If the file moves or is deleted, the build fails instead of silently serving a stale example.
Admonitions¶
Use them deliberately — a page where everything is highlighted highlights nothing.
!!! note
Neutral, useful aside.
!!! tip
A better way to do the thing.
!!! warning
Doing this wrong degrades security or breaks something.
!!! danger
Doing this wrong exposes the application to attack.
??? example "Collapsed by default"
Long configuration dumps belong in a collapsed block.
Reserve warning and danger for genuine security consequences. This library is a firewall; readers need those to still mean something when they matter.
Screenshots¶
Screenshots live in docs/assets/images/<section>/, named for what they show:
docs/assets/images/
├── challenges/
│ ├── math-interstitial.png
│ └── altcha-interstitial.png
└── demo/
└── blocked-response.png
Reference them with a relative path and always write alt text:

Images are click-to-zoom automatically. To opt a specific image out — small inline icons, for instance — add { .off-glb } after it.
Capture guidelines¶
| Rule | Why |
|---|---|
| Capture at 1280–1440px wide, 2× DPI | Sharp on retina, still legible when the theme scales it down. |
| PNG for UI, JPG for photos | PNG keeps text edges crisp; JPG keeps photo file sizes down. |
| Keep files under ~300 KB | They are committed to the repo and served from GitHub Pages. |
| Crop to the relevant region | A full-desktop screenshot to show one dialog wastes the reader's attention. |
| Use the light theme unless the page is about dark mode | Screenshots do not follow the reader's theme toggle, so a dark screenshot on a light page looks broken. |
| Never capture real IPs, hostnames, tokens, or customer data | Use 192.0.2.0/24, example.com, and obviously-fake secrets. |
Add a caption by giving the image a title, which the lightbox also picks up:

Reproducing the app for a screenshot¶
The demo application renders the real interstitials and block pages:
See Demo Application for the available routes.
Diagrams¶
Prefer a Mermaid diagram over a screenshot of a diagram — it stays editable, it scales, and it adapts to the reader's theme.
```mermaid
flowchart LR
A[Request] --> B{allow match?}
B -->|yes| C[Allow]
B -->|no| D[block plugins]
```
Checklist before opening the PR¶
-
mkdocs build --strictpasses locally, or CI is green on the PR - New pages are listed in
nav:inmkdocs.yml - Exactly one
#heading per page, no skipped heading levels - Cross-references use relative
.mdpaths - Code fences declare a language
- Every option documented states its default
- Screenshots have alt text, are cropped, and contain no real data
- No secrets, customer data, or internal hostnames anywhere
What CI does with your PR¶
CircleCI builds the site on every branch and every pull request and attaches the rendered HTML as a build artifact, so a reviewer can read your change as a real page before approving it. Open the Artifacts tab on the docs-build job and click docs/index.html.
When your change goes live¶
Merging to 2.x does not publish. The site is deployed only when a stable release tag is pushed — v2.9.0 publishes, v2.9.0-beta1 does not.
That means https://kanopi.github.io/firewall/ always describes the last release, so a reader can trust it matches the version they installed. The tradeoff is that a merged documentation fix is not visible publicly until the next release goes out. Until then, the PR artifact is the way to read it.
Mechanically, the docs deploy job hands the site that docs-build already produced to ghp-import, which commits the rendered HTML to the gh-pages branch and pushes it. GitHub Pages serves that branch. Nothing on 2.x changes — gh-pages holds only built output, never source.
It deliberately does not use mkdocs gh-deploy: that command always builds first, so it would throw away the artifact a reviewer approved and rebuild from source inside the deploy job. ghp-import is what gh-deploy calls underneath, so the published result is the same minus the rebuild.