Skip to content

Commit 797d31b

Browse files
committed
Add changelog, update docs for audit fixes, add CI workflow
Docs: - Add changelog.md page documenting v0.2.0 security hardening and v0.1.0 initial release - Update reference.md: port validation docs, new troubleshooting entries for port/path errors - Update internals.md: document config type validation, parser XSS protection, code fence tracking, unclosed block handling, data- attribute support, path traversal guards - Update configuration.md: note on favicon path security and pages type requirement - Add "Project" nav group with changelog link to docs.yaml CI: - Add ci.yml workflow: Python syntax check, pycodestyle lint, multi-version build verification (3.9 + 3.12), smoke tests for config validation, path traversal, XSS rejection, port validation, and parser edge cases - Update deploy.yml to use requirements.txt instead of hardcoded pip install
1 parent c77e22f commit 797d31b

7 files changed

Lines changed: 250 additions & 28 deletions

File tree

.github/workflows/ci.yml

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
lint:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: "3.12"
18+
19+
- name: Check syntax (py_compile)
20+
run: python3 -m py_compile phosphor/cli.py phosphor/build.py phosphor/config.py phosphor/parser.py phosphor/renderer.py phosphor/search.py
21+
22+
- name: Check formatting (basic style)
23+
run: |
24+
pip install pycodestyle
25+
pycodestyle --max-line-length=160 --ignore=E501,W503 phosphor/
26+
27+
build:
28+
runs-on: ubuntu-latest
29+
strategy:
30+
matrix:
31+
python-version: ["3.9", "3.12"]
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- uses: actions/setup-python@v5
36+
with:
37+
python-version: ${{ matrix.python-version }}
38+
39+
- name: Install dependencies
40+
run: pip install pyyaml
41+
42+
- name: Build phosphor-docs site
43+
run: python3 -m phosphor.cli build .
44+
45+
- name: Verify build output
46+
run: |
47+
test -d _site
48+
test -f _site/index.html
49+
test -f _site/changelog.html
50+
test -f _site/assets/style.css
51+
test -f _site/assets/search.js
52+
test -f _site/assets/favicon.svg
53+
echo "Build output verified: all expected files present"
54+
55+
- name: Verify page count
56+
run: |
57+
count=$(ls _site/*.html | wc -l)
58+
echo "Built $count pages"
59+
test "$count" -ge 6
60+
61+
smoke-test:
62+
runs-on: ubuntu-latest
63+
steps:
64+
- uses: actions/checkout@v4
65+
66+
- uses: actions/setup-python@v5
67+
with:
68+
python-version: "3.12"
69+
70+
- name: Install dependencies
71+
run: pip install pyyaml
72+
73+
- name: Test config validation rejects bad types
74+
run: |
75+
tmpdir=$(mktemp -d)
76+
echo 'pages: "not-a-list"' > "$tmpdir/docs.yaml"
77+
mkdir -p "$tmpdir/pages"
78+
if python3 -m phosphor.cli build "$tmpdir" 2>&1; then
79+
echo "FAIL: should have rejected string pages"
80+
exit 1
81+
fi
82+
echo "PASS: invalid config type rejected"
83+
84+
- name: Test path traversal blocked
85+
run: |
86+
python3 -c "
87+
from phosphor.build import _is_safe_path
88+
import os, tempfile
89+
d = tempfile.mkdtemp()
90+
assert not _is_safe_path(os.path.join(d, '../../etc/passwd'), d)
91+
print('PASS: path traversal blocked')
92+
"
93+
94+
- name: Test XSS URL rejection
95+
run: |
96+
python3 -c "
97+
from phosphor.parser import _escape_url
98+
assert _escape_url('javascript:alert(1)') == '#'
99+
assert _escape_url('https://example.com') == 'https://example.com'
100+
print('PASS: javascript: URIs rejected')
101+
"
102+
103+
- name: Test port validation
104+
run: |
105+
python3 -c "
106+
port = 99999
107+
assert not (1 <= port <= 65535), 'Port 99999 should be invalid'
108+
port = 8000
109+
assert 1 <= port <= 65535, 'Port 8000 should be valid'
110+
print('PASS: port validation logic correct')
111+
"
112+
113+
- name: Test parser edge cases
114+
run: |
115+
python3 -c "
116+
from phosphor.parser import parse_markdown
117+
# Code fence inside ::: block
118+
md = ':::tip Test\n\`\`\`\n::: not a closer\n\`\`\`\n:::'
119+
html, _ = parse_markdown(md)
120+
assert 'callout' in html, 'Tip block should render'
121+
print('PASS: code fence inside ::: block')
122+
"

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
with:
2525
python-version: "3.12"
2626

27-
- run: pip install pyyaml
27+
- run: pip install -r requirements.txt
2828

2929
- run: python3 -m phosphor.cli build .
3030

docs.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,18 @@ nav:
121121
page: "internals.md"
122122
anchor: "extending-phosphor"
123123

124+
- group: "Project"
125+
items:
126+
- label: "Changelog"
127+
icon: "scroll-text"
128+
page: "changelog.md"
129+
anchor: "changelog"
130+
124131
pages:
125132
- index.md
126133
- getting-started.md
127134
- writing-content.md
128135
- configuration.md
129136
- reference.md
130137
- internals.md
138+
- changelog.md

pages/changelog.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
## Changelog
2+
3+
Release history for Phosphor Docs. Each entry documents what changed and why.
4+
5+
## v0.2.0 — Security Hardening & Robustness
6+
7+
Released 2026-02-18.
8+
9+
A comprehensive security and stability audit uncovered 40+ issues across the parser, build system, config loader, CLI, and renderer. All have been fixed in this release.
10+
11+
### Security Fixes
12+
13+
:::warn Critical — update recommended
14+
These fixes address real security vulnerabilities. If you accept user-contributed content (Markdown files, config values), update immediately.
15+
:::
16+
17+
- **XSS in URLs**: All links, images, and hero buttons now escape URLs and reject `javascript:` URIs
18+
- **Path traversal in build**: Custom favicon paths and page file paths are validated to stay within the project directory. Paths containing `../` that escape the project are rejected
19+
- **SVG injection in favicon**: Theme color values injected into auto-generated favicons are now validated against safe patterns (`#hex` or `rgba()`)
20+
21+
### Parser Fixes
22+
23+
- **Code fences inside `:::` blocks**: A `:::` delimiter inside a code fence (` ``` `) is no longer treated as a component close. You can now safely show `:::` syntax in code examples within callouts, accordions, and other components
24+
- **Unclosed `:::` blocks**: If a `:::tip` or other component block is never closed, the parser now warns to stderr and renders the content instead of silently consuming the rest of the file
25+
- **Hyphenated component types**: The depth tracking regex now matches types like `decision-grid` (previously only matched `\w+` which excludes hyphens)
26+
- **Heading regex performance**: Limited a repeating group in heading extraction to prevent potential catastrophic backtracking on malformed HTML
27+
- **Table column padding**: Short table rows are now padded with empty cells to match the header column count, preventing misaligned tables
28+
- **`data-` attribute support**: Component attribute parsing now accepts hyphenated names like `data-x="value"`
29+
30+
### Config & Build Robustness
31+
32+
- **Config type validation**: The config loader now validates that `pages` and `nav` are lists, `site` and `theme` are mappings. Wrong types produce clear error messages instead of downstream crashes
33+
- **Template existence checks**: Missing `base.html` or `search.js` templates produce a clear error instead of an unhandled exception
34+
- **Output directory permissions**: Permission denied errors when cleaning `_site/` are caught and reported clearly
35+
- **Pages directory validation**: The build now checks that `pages/` exists before attempting to read files
36+
- **Non-string theme values**: Integer or null values in the theme config are silently skipped instead of producing invalid CSS
37+
38+
### CLI Improvements
39+
40+
- **Port validation**: `phosphor serve -p` rejects port numbers outside the valid range (1-65535)
41+
- **Port-in-use handling**: If the port is already in use, a friendly error message is shown instead of an unhandled exception
42+
- **Build failure handling**: Build errors during `phosphor serve` and `phosphor build` are caught with a clean error message
43+
- **Init guard**: `phosphor init` checks that the examples directory exists and handles directory creation failures
44+
- **SVG MIME type**: The serve command now serves `.svg` files with the correct `image/svg+xml` content type
45+
46+
### Other
47+
48+
- **PyYAML version pin**: Changed from `>=6.0` to `>=6.0,<7.0` to prevent breaking changes from a future major version
49+
- **install.sh**: Added `$HOME` environment check and chmod error handling
50+
51+
---
52+
53+
## v0.1.0 — Initial Release
54+
55+
Released 2026-02-17.
56+
57+
First public release of Phosphor Docs.
58+
59+
- Extended Markdown parser with 8 component types (callouts, cards, terminal blocks, command blocks, decision grids, accordions, pipelines, hero sections)
60+
- Dark Terminal Noir theme with full CSS variable theming
61+
- Client-side fuzzy search with keyboard navigation
62+
- Auto-themed gradient favicon from accent colors
63+
- `phosphor build`, `phosphor init`, `phosphor serve` CLI commands
64+
- Git auto-detection for `phosphor init`
65+
- GitHub Pages deployment workflow
66+
- Zero dependencies beyond Python 3 and PyYAML

pages/configuration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ pages:
133133
- Files not listed here are **not built** — this lets you keep drafts in `pages/` without publishing them
134134

135135
:::warn Every page must be listed
136-
If a Markdown file exists in `pages/` but isn't listed in the `pages` array, it won't be included in the build. This is intentional — it gives you control over what gets published.
136+
If a Markdown file exists in `pages/` but isn't listed in the `pages` array, it won't be included in the build. This is intentional — it gives you control over what gets published. The `pages` field must be a YAML list — strings or other types produce a clear error message.
137137
:::
138138

139139
## Navigation
@@ -463,7 +463,7 @@ site:
463463
favicon: "my-favicon.svg"
464464
```
465465

466-
Place the favicon file in your project directory (next to `docs.yaml`). SVG format is recommended. Custom favicons are copied as-is and not themed.
466+
Place the favicon file in your project directory (next to `docs.yaml`). SVG format is recommended. Custom favicons are copied as-is and not themed. The path must resolve within your project directory — paths that escape via `../` are rejected for security.
467467

468468
### Layout Breakpoints
469469

pages/internals.md

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ Config -> Parse -> Search -> Render -> Output
2424
Orchestrator. Loads config, iterates over pages, calls parser/renderer/search, copies assets, writes output. The only module that does file I/O for the build.
2525
::
2626

27-
::card{icon="settings" color="amber" title="config.py (38 lines)"}
28-
Loads docs.yaml with PyYAML, merges with DEFAULTS dict. Returns a plain dict. No validation beyond YAML parsing.
27+
::card{icon="settings" color="amber" title="config.py (~60 lines)"}
28+
Loads docs.yaml with PyYAML, merges with DEFAULTS dict. Validates types: `pages` and `nav` must be lists, `site` and `theme` must be mappings. Exits with clear error on invalid types.
2929
::
3030

31-
::card{icon="code" color="blue" title="parser.py (~550 lines)"}
32-
The largest module. Two-pass Markdown-to-HTML converter. Pass 1: fenced component blocks. Pass 2: standard Markdown. No external Markdown library.
31+
::card{icon="code" color="blue" title="parser.py (~650 lines)"}
32+
The largest module. Two-pass Markdown-to-HTML converter. Pass 1: fenced component blocks (with code fence tracking). Pass 2: standard Markdown. Includes XSS protection for URLs and `javascript:` URI rejection. No external Markdown library.
3333
::
3434

3535
::card{icon="layout-grid" color="purple" title="renderer.py (98 lines)"}
@@ -87,14 +87,18 @@ The h2 heading gets an auto-generated slug ID via `slugify()`. The h3 headings a
8787
The regex for `:::` block detection:
8888

8989
```
90-
^:::(tip|info|warn|cards|decision-grid|command|accordion|pipeline|hero)\s*(\{[^}]*\})?\s*(.*)$
90+
^:::([a-z][a-z0-9-]*)\s*(\{[^}]*\})?\s*(.*)$
9191
```
9292

93-
- Group 1: block type (required)
93+
- Group 1: block type — supports hyphenated names like `decision-grid` (required)
9494
- Group 2: attribute string like `{title="..." usage="..."}` (optional)
9595
- Group 3: inline text after the type (used for callout titles like `:::tip My Title`)
9696

97-
Nesting is tracked with a depth counter. Each `:::\w+` increments depth, each `:::` (bare) decrements. When depth hits 0, the block is closed.
97+
Nesting is tracked with a depth counter. Each `:::type` increments depth, each `:::` (bare) decrements. When depth hits 0, the block is closed.
98+
99+
**Code fence awareness**: The fenced block scanner tracks code fence state. A `:::` delimiter inside a code fence (`` ``` ``) is ignored — it does not open or close a component block. This prevents content corruption when showing `:::` syntax examples inside code blocks.
100+
101+
**Unclosed blocks**: If a `:::type` block never finds its closing `:::`, the parser emits a warning to stderr and treats the remaining content as the block body rather than silently consuming it.
98102

99103
### How Code Fences Work
100104

@@ -125,21 +129,24 @@ Cards and commands use `::child{attrs}` syntax (double colon, no triple). These
125129
::flag\{([^}]*)\}\s*\n(.*?)(?=::flag|\Z)
126130
```
127131

128-
Each child's attribute string is parsed by `_parse_attrs()` which extracts `key="value"` pairs.
132+
Each child's attribute string is parsed by `_parse_attrs()` which extracts `key="value"` pairs. Attribute names support hyphens (e.g., `data-x="value"`) in addition to standard alphanumeric names.
129133

130134
### Inline Processing
131135

132136
The `_inline(text)` function handles inline Markdown within any line:
133137

134-
1. Images: `![alt](src)` -> `<img>`
135-
2. Links with classes: `[text](url){.class}` -> `<a class="hero-btn class">`
136-
3. Regular links: `[text](url)` -> `<a>`
137-
4. Inline code: `` `code` `` -> `<code>`
138+
1. **Code spans extracted first** — replaced with placeholders to protect from further processing
139+
2. Images: `![alt](src)` -> `<img>` (URL escaped, alt text escaped)
140+
3. Links with classes: `[text](url){.class}` -> `<a class="hero-btn class">`
141+
4. Regular links: `[text](url)` -> `<a>`
138142
5. Bold: `**text**` -> `<strong>`
139143
6. Italic: `*text*` -> `<em>`
144+
7. **Code spans restored** from placeholders
140145

141146
Order matters — images are processed before links to prevent `![` being matched as a regular link.
142147

148+
**URL security**: All URLs in links, images, and hero buttons are processed through `_escape_url()`, which HTML-escapes special characters (quotes, ampersands) and rejects `javascript:` URIs by replacing them with `#`.
149+
143150
### HTML Block Pass-Through
144151

145152
After pass 1, the text contains embedded HTML from component parsers. Pass 2 must not wrap these in `<p>` tags. The HTML block detection regex matches lines starting with known block-level HTML tags (both opening and closing):
@@ -173,15 +180,17 @@ The `build(project_dir)` function:
173180

174181
4. **Cleans output**: Deletes `_site/` entirely and recreates it. Every build is a clean build.
175182

176-
5. **Copies assets**: Copies `style.css`, `script.js`, and `favicon.svg` from `theme/` to `_site/assets/`. If a custom favicon is specified in config, it's copied over the default.
183+
5. **Copies assets**: Copies `style.css`, `script.js`, and `favicon.svg` from `theme/` to `_site/assets/`. If a custom favicon is specified in config, it's copied only if the path resolves within the project directory (path traversal protection). Auto-generated favicons validate that theme colors match safe patterns (`#hex` or `rgba()`) before injecting them into SVG.
177184

178-
6. **Parses pages**: For each `.md` file in the `pages` config array, reads the file from `pages/` directory, calls `parser.parse_markdown()`, and stores the result.
185+
6. **Validates and parses pages**: Checks that `pages/` directory exists. For each `.md` file in the `pages` config array, verifies the resolved path stays within `pages/` (path traversal protection), then reads the file and calls `parser.parse_markdown()`.
179186

180187
7. **Generates search**: Calls `search.build_search_index()` with all parsed page data. Injects the JSON index into the search.js template and writes it to `_site/assets/search.js`.
181188

182189
8. **Renders pages**: For each parsed page, calls `renderer.render_page()` which substitutes template variables and writes the HTML to `_site/`.
183190

184-
### Config Defaults
191+
### Config Validation and Defaults
192+
193+
The config loader validates types before merging with defaults. `pages` and `nav` must be lists (or null/absent for defaults). `site` and `theme` must be mappings. Invalid types produce a clear error message and exit.
185194

186195
When `docs.yaml` is missing a field, these defaults apply:
187196

pages/reference.md

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ Site built to /home/user/my-docs/_site/
2525

2626
The build process:
2727

28-
1. Loads and validates `docs.yaml`
29-
2. Reads each `.md` file listed in the `pages` array
30-
3. Parses Markdown into HTML (standard + extended components)
31-
4. Generates the search index from all headings and content
32-
5. Renders each page into the base HTML template
33-
6. Copies theme assets (CSS, JS, favicon) to `_site/assets/`
34-
7. Writes the final HTML files to `_site/`
28+
1. Loads and validates `docs.yaml` (checks types: `pages` and `nav` must be lists, `site` and `theme` must be mappings)
29+
2. Verifies that `pages/` directory exists and that all page paths stay within it (path traversal protection)
30+
3. Reads each `.md` file listed in the `pages` array
31+
4. Parses Markdown into HTML (standard + extended components)
32+
5. Generates the search index from all headings and content
33+
6. Renders each page into the base HTML template
34+
7. Copies theme assets (CSS, JS, favicon) to `_site/assets/`
35+
8. Writes the final HTML files to `_site/`
3536

3637
:::info Clean builds
3738
Every build deletes and recreates the `_site/` directory from scratch. This ensures no stale files remain from previous builds. The `_site/` directory should be in your `.gitignore`.
@@ -83,11 +84,11 @@ Key behaviors:
8384
Path to the project directory containing `docs.yaml`. Defaults to the current directory.
8485
::
8586
::flag{name="--port" short="-p"}
86-
Port number for the local HTTP server. Defaults to 8000.
87+
Port number for the local HTTP server (1-65535). Defaults to 8000.
8788
::
8889
:::
8990

90-
Builds the site and starts a local HTTP server for previewing. This is a convenience command that combines `phosphor build` with Python's built-in HTTP server.
91+
Builds the site and starts a local HTTP server for previewing. This is a convenience command that combines `phosphor build` with Python's built-in HTTP server. Port numbers outside the valid range (1-65535) are rejected with a clear error. If the port is already in use, Phosphor prints a helpful message instead of crashing.
9192

9293
```terminal
9394
$ phosphor serve
@@ -202,7 +203,7 @@ The base HTML template at `templates/base.html` defines the page shell:
202203
| Dependency | Version | Purpose |
203204
| --- | --- | --- |
204205
| Python 3 | 3.8+ | Runtime |
205-
| PyYAML | 6.0+ | `docs.yaml` parsing |
206+
| PyYAML | 6.0 - 6.x | `docs.yaml` parsing |
206207
| Lucide Icons | CDN (latest) | Icon library (loaded at runtime from CDN) |
207208
| Google Fonts | CDN | Chakra Petch, Nunito Sans, JetBrains Mono |
208209

@@ -313,6 +314,22 @@ The search index might be empty. Verify:
313314
If your docs project is on the Windows filesystem (`/mnt/c/...`), file operations are slow due to the WSL2 filesystem bridge. Move your project to the Linux filesystem (`~/my-docs/`) for faster builds.
314315
:::
315316

317+
:::accordion{title="Error: port must be between 1 and 65535"}
318+
The port number passed to `phosphor serve -p` is out of range. Use a port between 1 and 65535. Common choices: 3000, 5000, 8000, 8080.
319+
:::
320+
321+
:::accordion{title="Error: port is already in use"}
322+
Another process is using that port. Either stop the other process or use a different port: `phosphor serve -p 3001`
323+
:::
324+
325+
:::accordion{title="Error: page path escapes pages/ directory"}
326+
A file in the `pages` list references a path outside the `pages/` directory (e.g., `../../../etc/passwd`). Page paths must be simple filenames within the `pages/` directory. Remove any `../` or absolute paths from the `pages` list.
327+
:::
328+
329+
:::accordion{title="Error: favicon path escapes project directory"}
330+
The `favicon` value in `docs.yaml` points outside the project directory. The favicon path must be relative and resolve within the project root. Remove any `../` sequences that escape the project.
331+
:::
332+
316333
:::accordion{title="Lucide icons not rendering"}
317334
Icons appear as empty squares or text. This means the Lucide CDN script isn't loading. Check:
318335
1. You have internet connectivity (Lucide loads from `unpkg.com`)

0 commit comments

Comments
 (0)