$1');
h = h.replace(/^### (.+)$/gm, 'nexCMS
# nexCMS: Building a CMS With Nothing But WSGI and Stubbornness
## What it is
nexCMS is a single-file Python WSGI application that runs this site. No Django, no Flask, no Node — just `wsgi.py` talking directly to the WSGI spec, a folder of JSON files acting as the database, and a vanilla JavaScript frontend pulling from a handful of REST-ish endpoints. The whole content model is four kinds of objects: pages (the sticky notes), todos, links, and site settings. That's it.
The honest reason to build it this way wasn't "no CMS does what I want." It's that I wanted to know what a CMS actually *is* under the hood — routing, sessions, auth, a data layer — instead of trusting that a framework handles those correctly for me. Which meant hitting, and fixing, all the ways they can go wrong myself.
## Architecture at a glance
```
Request comes in
|
v
application(environ, start_response) <- single entry point, no router library
|
v
Manual if/elif chain over PATH_INFO + REQUEST_METHOD
|
+--> Static files (/static/, /themes/, /img/, or by extension)
+--> Public pages (/, /page/)
+--> Admin (session-gated, serves admin.html)
+--> API routes (/api/pages, /api/todos, /api/links, /api/settings, /api/login...)
|
v
Data layer: flat JSON files under data/
|
+--> data/pages/.json (one file per note)
+--> data/todos.json
+--> data/links.json
+--> data/settings.json
+--> data/sessions.json
```
There's no ORM, no template engine, no request router library. `application()` is one long chain of `if path == X and method == Y` checks. It's not elegant, but it's also not hiding anything — every route is a straight line from URL to handler, which made debugging genuinely easier than I expected once things started breaking.
## The data layer: JSON files as a database
Every "note" on the board is its own JSON file in `data/pages/`, named by slug:
```python
def _save_page(slug, data):
if not slug or '..' in slug or '/' in slug: return False
try:
filepath = PAGES_DIR / f'{slug}.json'
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return True
except Exception as e:
_log(f"❌ Failed to save {slug}: {e}")
return False
```
Reading all pages is just globbing the directory and sorting by timestamp:
```python
def _get_all_pages():
pages = []
for f in PAGES_DIR.glob('*.json'):
data = _safe_json_load(f)
if data: pages.append(data)
pages.sort(key=lambda p: p.get('created_at',''), reverse=True)
return pages
```
This is a genuinely fine approach for a single-user, low-write-volume site. It's also a real database with real database problems if you don't respect that:
- **No transactions.** If the process dies mid-write, you get a truncated or empty JSON file for that page. `_safe_json_load` swallows the parse failure and returns `None`, so a corrupted file just silently disappears from the board rather than crashing anything — which is the right failure mode for content, but worth knowing.
- **No concurrency control.** `sessions.json`, `todos.json`, and `links.json` are each a single file, read-modify-written as a whole. Two near-simultaneous writes (say, an admin session save racing a login token cleanup) can clobber each other — last write wins, silently. On a single-admin personal site this is a low-probability problem. It would not scale to concurrent editors without adding real locking.
- **The slug is the primary key**, and it's derived from the title (`_slugify`), with collision handling by appending `-1`, `-2`, etc. Rename the title later and the slug doesn't follow — which is correct behavior (you don't want to break existing links to a page) but means slug and title can drift apart over time, silently.
None of this is a criticism of the approach — it's the tradeoff you accept when you choose "understand every byte" over "let Postgres handle it." It's just worth naming honestly rather than pretending flat files are a database with no asterisks.
## Auth and sessions
Login is username/password against a single credentials file, hashed with salted SHA-256:
```python
def _verify_credentials(username, password):
creds = json.loads(CREDENTIALS_FILE.read_text())
if username != creds.get('username'): return False
salt = creds.get('salt', '')
return hashlib.sha256((password + salt).encode()).hexdigest() == creds.get('password_hash')
```
This is worth being straight about: salted SHA-256 is *not* what you'd want for a password hash defending against an offline brute-force attempt if the credentials file ever leaked — SHA-256 is fast, which is exactly the wrong property for a password KDF. `bcrypt`, `scrypt`, or `argon2` are the correct tools here, deliberately slow to make brute-forcing expensive. For a single-admin personal site where the credentials file lives outside the webroot and isn't otherwise exposed, the practical risk is low — but if this pattern gets reused anywhere with more at stake, that's the first thing to swap out.
Sessions are opaque tokens (`secrets.token_hex(32)` — this part's solid, cryptographically random) stored in a flat JSON dict mapping token → `{user, expires}`, set as an `HttpOnly`, `SameSite=Strict` cookie. Expired sessions get swept on next write. Reasonable for the scale this runs at.
## Routing: the dispatch table that isn't
There's no route registry, no decorators, no pattern matching library. Every route is a literal string or prefix check, evaluated top to bottom:
```python
if path in ('/','/index.html') and method == 'GET':
...
if path.startswith(('/static/', '/themes/', '/img/')) or path.endswith(('.css', '.js', ...)):
...
if path.startswith('/page/') and method == 'GET':
...
```
The tradeoff: adding a route means finding the right spot in a growing chain rather than declaring it somewhere clean. What you get in exchange is that there's no routing magic to debug — the entire request lifecycle for any URL is readable top to bottom in one file, which mattered a lot when I was chasing the silent-failure bugs described below.
## The frontend: no framework, no build step
`app.js` is a single IIFE (`const CMS = (() => {...})()`) that fetches JSON from the API on load and renders everything client-side — the note board, calendar, todo list, links widget. No React, no bundler, no npm install. `` and that's the entire build pipeline.
The markdown renderer inside a page's content is intentionally minimal — a handful of regex passes for bold, italic, inline code, headers, and images:
```javascript
h = h.replace(/\*\*(.+?)\*\*/g, '$1');
h = h.replace(/\*(.+?)\*/g, '$1');
h = h.replace(/`(.+?)`/g, '