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_NAME is wptransformer_db β€” crumbforest_db exists 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.php requires admin login and must be deleted after running
  • FTP password is never stored β€” passed per-request only (see tenant.php line 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(), no system(), no shell execution anywhere in the codebase
  • display_errors = 0 in 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_deployed timestamps on wpt_tenants document 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.