CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project
WPTransformer β PHP multi-tenant app that converts WordPress MySQL dumps into self-contained static HTML sites. No framework, no Composer. PHP 8.2+, dual-driver PDO (MySQL or PostgreSQL).
Read ABSTRACT.md for the process philosophy. Read CODEBASE.md for deep per-file and per-class documentation with line numbers.
Commands
Deploy to VPS (transformer.crumbforest.io):
bash scripts/deploy_transformer.sh
Pre-deploy health check (run on target server):
bash wptransformer-doktor.sh [/var/www/app-dir]
# or remotely:
ssh root@87.106.12.97 'bash -s' < wptransformer-doktor.sh
Server discovery (before any deploy β never guess server state):
ssh root@87.106.12.97 'bash -s' < scripts/init_test.sh
Recompile Tailwind CSS (after changing public pages):
npx tailwindcss \
-c ~/Retro_PWD_Reset/tailwind.config.js \
-i assets/css/landing.css \
-o assets/css/landing.css \
--minify \
--content "./index.php,./service.php,./impressum.php,./datenschutz.php"
tailwind.config.js lives outside this repo at ~/Retro_PWD_Reset/ (shared across projects).
Create a release snapshot:
./build-release.sh 1.0.5
Diffs against last git tag, copies changed webroot files to releases/v1.0.5/, excludes docs/secrets/assets.
DB schema (first deploy only):
- PostgreSQL (VPS): init_pg.sql β run via psql or deploy_transformer.sh
- MySQL (ALL-INKL): init.sql β import via phpMyAdmin
Schema migration (after upgrading):
Open https://transformer.crumbforest.io/migrate_103.php in browser while logged in as admin, then delete the file.
Architecture
Two-server setup
| Server | Role | DB |
|---|---|---|
Strato VPS transformer.crumbforest.io (87.106.12.97) |
App + pipeline engine | PostgreSQL (wptransformer_db) |
ALL-INKL wptransformer.org |
Static output destination + tenant subdomains | MySQL |
The app runs on the VPS. Generated tenant HTML lands on ALL-INKL via FTP deploy or ZIP download. No PHP pipeline runs on ALL-INKL.
Pipeline
SQL Dump (upload)
β
WPParser pure-PHP tokenizer β no MySQL needed, reads INSERT statements as text
β
wpt_posts table editable intermediate layer β human review happens here
β
StaticBuilder generates public/{slug}.html, index.html, feed.json, .htaccess
β
βββ ZIP download
βββ FTP deploy β tenant subdomain (ozm.wptransformer.org, merkur.wptransformer.org)
Build reads from wpt_posts (DB) by preference β manual edits in the post editor flow into the build. content.json is a fallback only.
Class responsibilities
| Class | Responsibility |
|---|---|
Db.php |
PDO singleton, dual-driver (DB_TYPE=pgsql or mysql), one connection per request |
Auth.php |
Session, login (bcrypt cost 12), password reset (SHA-256 token, 1h TTL, enumeration-safe) |
Csrf.php |
Token generation and hash_equals() verification β required on all POST forms |
WPParser.php |
Character-by-character SQL scanner, auto-detects DB prefix, strips Gutenberg/shortcodes |
TenantManager.php |
CRUD for wpt_tenants, brand/deploy stored as JSON columns |
StaticBuilder.php |
HTML generator, theme system via templates/themes/, inlines app.css |
Deployer.php |
ZIP export (recursive), FTP upload (SSL-FTP with plain-FTP fallback, passive mode) |
Logger.php |
Structured NDJSON logging to logs/ |
Page pattern
All protected pages follow:
require 'config.php'; // constants + loads Db.php
require 'classes/Auth.php';
Auth::start();
Auth::check(); // redirects to /login.php if not authenticated
Output buffering: templates write into a buffer via ob_start()/ob_get_clean(), then layout.php embeds $content.
tenant.php routes all pipeline actions via ?action= (new, create, upload, parse, build, zip, ftp, update_config). Slug is sanitised at line 11 with a whitelist regex before any action runs.
Configuration
config.php loads config.local.php (never in git, blocked by .htaccess). If missing, empty constants are defined so PHP doesn't fatal β but the app shows a setup redirect.
DB_TYPE defaults to mysql if not defined β ALL-INKL works without touching config.local.php.
VPS config.local.php:
define('DB_TYPE', 'pgsql');
define('DB_HOST', 'localhost');
define('DB_NAME', 'wptransformer_db'); // not wptransformer β that DB is taken
define('DB_USER', 'wptransformer');
define('DB_PASS', '...');
define('PASSWORD_PEPPER', '...');
define('MAIL_TYPE', 'file');
define('APP_ENV', 'production');
Critical facts (don't re-derive)
DB_NAMEiswptransformer_dbβcrumbforest_dbexists on the same server and would conflict- PHP-FPM socket:
/run/php/php8.2-fpm.sockβ Debian Bookworm uses 8.2, not 8.3/8.4 - No Composer β all dependencies are hand-rolled PHP classes in
classes/ - Tailwind is pre-compiled β no build step at runtime; recompile manually after HTML changes
migrate_103.phprequires admin login and must be deleted after running- FTP password is never stored β passed per-request only (see
tenant.phpline 125) - Tenant subdomains are configured in the ALL-INKL KAS panel, pointing to
tenants/{slug}/public/
Security patterns to maintain
- All POST forms:
Csrf::field()in template,Csrf::verify()at top of controller - All tenant paths: slug sanitised with
preg_replace('/[^a-z0-9\-]/', '', ...) - Upload validation: extension check (
strtolower(pathinfo(...)) !== 'sql') - No
eval(), nosystem(), no shell execution anywhere in the codebase display_errors = 0in config.php β errors go to Logger, not to browser
Planned CLI build (next evolution)
The current codebase is a browser-based web app. The next evolution is a pure CLI tool β same pipeline, no browser UI. The full spec lives in PROMPT.md.
Key differences from the current web app:
| Web App (current) | CLI (planned, PROMPT.md) |
|
|---|---|---|
| Interface | Browser dashboard | ./bin/wpt with flags |
| DB | MySQL / PostgreSQL | SQLite (data/intermediate.sqlite) |
| Structure | classes/ flat |
src/Parser/, src/Builder/, src/HumanLoop/ |
| Deploy trigger | Button in browser | --target nullfeld / --ftp / --usb |
| Human Loop | Manual dashboard step | ReviewInterface + ConfirmStep CLI prompt |
Build order (when starting the CLI build):
1. bin/wpt β shebang + argument router
2. src/Parser/WPSQLParser.php β SQL β array (core logic)
3. src/Intermediate/IntermediateDB.php β SQLite import
4. src/HumanLoop/ReviewInterface.php β CLI review output
5. tests/ParserTest.php β fixture-based test
No Composer, no external packages. Only PHP built-ins: SQLite3, DOMDocument.
Pipeline states as archive (not just migration)
The four pipeline states (pending β parsed β built β deployed) are not just workflow steps β they are persistent content states. A tenant's posts can exist in the DB (wpt_posts) without ever being built or deployed publicly.
This makes WPTransformer a content archive, not just a migration tool:
- Old WordPress content is preserved in the intermediate layer indefinitely
- Editors can review, rewrite, or hold content before it ever becomes a static page
- Content that shouldn't go public (outdated posts, drafts, sensitive pages) stays in the DB as historical record
- The pipeline log and
last_parsed/last_built/last_deployedtimestamps onwpt_tenantsdocument when each state was reached
Example β OZM tenant: Has imported content going back years. Not everything is deployed. The unpublished posts exist in wpt_posts as an internal archive, browsable via the post editor, without ever appearing on ozm.wptransformer.org.
This is a selling point distinct from "static site generator": the customer gets both a live site and a preserved, editable record of everything that was ever in their WordPress β including what they chose not to publish.
When extending the pipeline (new states, new export types), preserve this principle: the intermediate DB layer is the source of truth, not the generated output.
Git remote
origin: https://git.crumbforest.org/branko/wptransformeer-OZMv0.git
Self-hosted Gitea on the Nullfeld server (194.164.194.191). No auto-deploy β run deploy_transformer.sh explicitly after push.