π§ QuizWizz β Environment Setup + Milestone m1 Build Plan
Context
Stand up a clean, minimal WordPress install on a new vhost qwizz.0rk.de (Debian server) running PHP 8.4 (deb.sury.org) + MariaDB 10.11 behind Nginx + a freshly issued Certbot TLS cert, then build and verify the QuizWizz milestone m1 product: qw_quiz/qw_question CPTs + taxonomies, an OpenTDB importer, a dynamic Gutenberg block that plays a quiz in-browser via Alpine.js with client-side grading, and a printable results/answer-key sheet. The SSOT plan at https://ssot.0rk.de/projects/quizwizz/plan is published FIRST and updated after EVERY phase (SSOT-First Mandate below) for transparency and durable memory. End state: SSOT shows every phase β
, the plugin activates clean, passes Plugin Check, and a real browser can take and print an imported quiz.
β
quiz.0rk.de(/var/www/quiz.0rk.de) is a SEPARATE, live project and is NEVER touched by this plan. QuizWizz lives ONLY atqwizz.0rk.deβ/var/www/qwizz.0rk.de. Every path, URL, nginx file, and cert below uses theqwizzspelling β do not substitutequiz.
Out of m1 (do not build): OpenRouter gateway, batch translation, the chat-bubble wizard, Data Views admin, the React settings page, Web Share, mPDF. These are m2βm4.
flowchart LR DS["OpenTDB API"] -->|"import: base64 decode + map"| CPT["qw_quiz / qw_question CPTs"] CPT -->|"GET /quizwizz/v1/quiz/{id} (with answers)"| PLAY["Alpine player in browser"] PLAY -->|"local grading"| PLAY PLAY -->|"window.print()"| PRINT["printable sheet + key"]
Target topology
| Component | Value |
|---|---|
| Domain / webroot | qwizz.0rk.de β /var/www/qwizz.0rk.de (created fresh; absent today) |
| DNS | qwizz.0rk.de A β 173.212.213.72 = this host (verified) β ACME HTTP-01 will pass |
| TLS | new Letβs Encrypt cert via certbot certonly --webroot (none exists yet) |
| PHP | 8.4 FPM via deb.sury.org Β· socket /run/php/php8.4-fpm.sock Β· pool user www-data |
| DB | MariaDB 10.11.11 Β· db quizwizz (+ quizwizz_test) Β· user quizwizz_user@localhost Β· prefix wp_ |
| WordPress | latest stable (7.0; wp core download --version=latest) Β· permalinks /%postname%/ |
| Plugin slug | quizwizz β /var/www/qwizz.0rk.de/wp-content/plugins/quizwizz |
| Block name | quizwizz/quiz (apiVersion 3, dynamic, server-rendered) Β· REST quizwizz/v1 |
| SSOT doc | /home/dv/quartz-ssot/content/projects/quizwizz/plan.md β npm run quartz build |
Two command helpers used throughout (www-data owns all webroot files; HOME/caches must be writable):
WP="sudo -u www-data env HOME=/tmp WP_CLI_CACHE_DIR=/tmp/.wp-cli wp --path=/var/www/qwizz.0rk.de"
RUN="sudo -u www-data env HOME=/tmp COMPOSER_HOME=/tmp/.composer" # for cwd-bound npm/npx/composerπ SSOT-First Mandate & Sync Ritual (non-negotiable)
Phase 0 (publish the plan to SSOT) MUST complete before any other phase. It changes no system state beyond the Quartz site, needs only Node (present), and makes the canonical plan public before infra work begins.
SSOT-SYNC ritual β run at the END of every phase (0β10): edit the Progress table row for the just-finished phase, set its Status cell (β done, or π΄ + a one-line note on failure), then rebuild:
cd /home/dv/quartz-ssot && npm run quartz buildThe SSOT doc is the durable memory of this work; never advance two phases without a sync between them. Each phase below ends with the marker β SSOT-SYNC.
Progress table (lives near the top of the SSOT doc; legend: β³ pending Β· π in progress Β· β done Β· π΄ failed):
| Phase | Step | Status |
| :-: | --- | :-: |
| 0 | Publish plan to SSOT | β
|
| 1 | PHP 8.4 (deb.sury.org) + FPM tuning | β
|
| 2 | Composer + WP-CLI | β
|
| 3 | Create qwizz.0rk.de webroot | β
|
| 4 | MariaDB DB/user/credentials | β
|
| 5 | Clean WordPress install | β
|
| 6 | Nginx vhost + Certbot TLS | β
|
| 7 | Scaffold plugin + block + tests | β
|
| 8 | Implement milestone m1 | β
|
| 9 | Lint + test gates | β
|
| 10 | Final SSOT reconciliation | β
|
| 11 | React Settings Page | β
|
| 12 | Data Views Admin | β
|
| 13 | OpenRouter Gateway | β
|
| 14 | Chat-bubble Wizard (Quiz Builder) | β
|
| 15 | Final SSOT reconciliation (m2) | β
|
| 16 | Craft PDF Page (mPDF) | β
|
| 17 | UI Perfection & Screenshot Validations | β
|Outcome: 2026-06-23 β Frontend and backend UI perfection audited successfully. Validated Admin UI and Crafting Panel functionality, ensuring correct escaping, nonces, and clean rendering. Both panels screenshots have been captured, successfully retrieved, and permanently retained to visually demonstrate the functioning interfaces.
Approach
Phases are strictly ordered; each leaves the system valid and ends with β SSOT-SYNC. Re-running a failed phase is safe β every phase is written idempotently (guards inline); the one-shot exception is Phase 7 (scaffolders), which must rm -rf a partial plugin dir before re-running and must NOT be re-run once Phase 8 edits have begun.
Phase 0 β Publish the plan to SSOT (FIRST)
Write /home/dv/quartz-ssot/content/projects/quizwizz/plan.md containing, in order: the SSOT frontmatter below; an H1 # π§ QuizWizz β Setup & m1 Build; the Progress table (all rows β³); the mermaid diagram from Context (Quartz rule: every mermaid edge label MUST be double-quoted, e.g. -->|"label"|); then the full Approach, Critical files & anchors, Verification, and Assumptions & contingencies sections from this spec, verbatim β including the β quiz.0rk.de off-limits note. Do not copy any generated secret into the SSOT doc (secrets live only in /root/quizwizz-credentials.txt).
The title is emoji-free (Quartz explorer strips title emojis via regex; the H1 keeps π§). Then build:
cd /home/dv/quartz-ssot && npm run quartz buildCheck: test -f /home/dv/quartz-ssot/public/plans/quizwizz.html && echo PUBLISHED; https://ssot.0rk.de/projects/quizwizz/plan renders with the Progress table. β SSOT-SYNC (set Phase 0 = β
).
Phase 1 β PHP 8.4 from deb.sury.org
No PHP is installed; Debian Bookworm only ships PHP 8.2, so the Sury repo is required to reach the specβs 8.3+ minimum. /etc/apt/keyrings exists; lsb_release present.
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl lsb-release
sudo curl -fsSLo /etc/apt/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg
echo "deb [signed-by=/etc/apt/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ bookworm main" \
| sudo tee /etc/apt/sources.list.d/sury-php.list
sudo apt-get update
sudo apt-get install -y \
php8.4-fpm php8.4-mysql php8.4-xml php8.4-mbstring php8.4-curl \
php8.4-zip php8.4-gd php8.4-intl php8.4-bcmath php8.4-opcache \
subversion
sudo update-alternatives --install /usr/bin/php php /usr/bin/php8.4 100subversion is needed by bin/install-wp-tests.sh (Phase 9). update-alternatives makes bare php resolve to 8.4 (WP-CLI invokes php). Write /etc/php/8.4/fpm/conf.d/99-quizwizz.ini (opcache.revalidate_freq=0 so plugin edits reflect immediately; cgi.fix_pathinfo=0 is mandatory hardening):
cgi.fix_pathinfo=0
upload_max_filesize=64M
post_max_size=64M
memory_limit=256M
max_execution_time=120
opcache.revalidate_freq=0sudo systemctl restart php8.4-fpmCheck: php -v β PHP 8.4.x; systemctl is-active php8.4-fpm β active; test -S /run/php/php8.4-fpm.sock && echo OK. β SSOT-SYNC.
Phase 2 β Composer + WP-CLI (global)
EXPECTED="$(curl -fsSL https://composer.github.io/installer.sig)"
curl -fsSLo /tmp/composer-setup.php https://getcomposer.org/installer
php -r "exit( hash_file('sha384','/tmp/composer-setup.php')==='$EXPECTED' ? 0 : 1 );" # must exit 0
sudo php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm /tmp/composer-setup.php
curl -fsSLo /tmp/wp-cli.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
php /tmp/wp-cli.phar --info
sudo install -m 0755 /tmp/wp-cli.phar /usr/local/bin/wp && rm /tmp/wp-cli.pharCheck: composer --version β 2.x; wp --info shows PHP 8.4. β SSOT-SYNC.
Phase 3 β Create the qwizz.0rk.de webroot (greenfield; nothing to back up)
/var/www/qwizz.0rk.de does not exist (verified) and quiz.0rk.de must not be touched β so this phase only creates and owns the new directory. No deletion occurs anywhere.
sudo mkdir -p /var/www/qwizz.0rk.de
sudo chown www-data:www-data /var/www/qwizz.0rk.de
sudo chmod 755 /var/www/qwizz.0rk.deCheck: [ -d /var/www/qwizz.0rk.de ] && echo OK; confirm the separate project is untouched: [ -f /var/www/quiz.0rk.de/index.html ] && echo QUIZ_INTACT. β SSOT-SYNC.
Phase 4 β MariaDB database, users, credentials
Root authenticates via unix socket (sudo mysql, no password). All statements use IF NOT EXISTS (idempotent). Persist generated secrets to a root-only file.
DB_PASS="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)"
ADMIN_PASS="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)"
sudo mysql <<SQL
CREATE DATABASE IF NOT EXISTS quizwizz CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS quizwizz_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'quizwizz_user'@'localhost' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON quizwizz.* TO 'quizwizz_user'@'localhost';
GRANT ALL PRIVILEGES ON quizwizz_test.* TO 'quizwizz_user'@'localhost';
FLUSH PRIVILEGES;
SQL
sudo install -m 600 /dev/stdin /root/quizwizz-credentials.txt <<EOF
SITE_URL=https://qwizz.0rk.de
DB_NAME=quizwizz
DB_USER=quizwizz_user
DB_PASS=${DB_PASS}
WP_ADMIN_USER=qwadmin
WP_ADMIN_PASS=${ADMIN_PASS}
WP_ADMIN_EMAIL=admin@0rk.de
EOFIf re-running with a fresh
DB_PASS, alsoALTER USER 'quizwizz_user'@'localhost' IDENTIFIED BY '${DB_PASS}';. KeepDB_PASS/ADMIN_PASSexported for Phases 5/9, or re-source/root/quizwizz-credentials.txt.
Check: mysql -u quizwizz_user -p"$DB_PASS" -e "USE quizwizz; SELECT 1;" β 1. β SSOT-SYNC.
Phase 5 β Clean WordPress install (WP-CLI, salts auto-generated)
Guards make each step idempotent.
$WP core is-installed 2>/dev/null || {
[ -f /var/www/qwizz.0rk.de/wp-load.php ] || $WP core download --version=latest --locale=en_US
[ -f /var/www/qwizz.0rk.de/wp-config.php ] || $WP config create \
--dbname=quizwizz --dbuser=quizwizz_user --dbpass="$DB_PASS" \
--dbhost=localhost --dbprefix=wp_ --extra-php <<'PHP'
define( 'WP_ENVIRONMENT_TYPE', 'development' );
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
define( 'DISALLOW_FILE_EDIT', true );
if ( false !== getenv( 'OPENROUTER_API_KEY' ) ) {
define( 'OPENROUTER_API_KEY', getenv( 'OPENROUTER_API_KEY' ) );
}
PHP
$WP core install --url=https://qwizz.0rk.de --title="QuizWizz" \
--admin_user=qwadmin --admin_password="$ADMIN_PASS" --admin_email=admin@0rk.de --skip-email
}
$WP rewrite structure '/%postname%/' --hard # clean /wp-json/ REST URLs + try_files routingwp config create writes fresh unique salts (a manual install would omit them β a real bug). WP_ENVIRONMENT_TYPE=development enables dev-appropriate core behavior. The OPENROUTER_API_KEY guard is forward-prep for m3, a no-op when unset. Pretty permalinks are required for /wp-json/quizwizz/v1/...; the nginx try_files β¦ /index.php?$args (Phase 6) supports them. Behind Certbot TLS, fastcgi.conf sets HTTPS=on, so WP renders https URLs (no mixed content). Lock down ownership:
sudo chown -R www-data:www-data /var/www/qwizz.0rk.de
sudo find /var/www/qwizz.0rk.de -type d -exec chmod 755 {} \;
sudo find /var/www/qwizz.0rk.de -type f -exec chmod 644 {} \;
sudo chmod 640 /var/www/qwizz.0rk.de/wp-config.phpCheck: $WP core is-installed && echo INSTALLED; $WP option get siteurl β https://qwizz.0rk.de; $WP option get permalink_structure β /%postname%/. β SSOT-SYNC.
Phase 6 β Nginx vhost + Certbot TLS (new site)
No nginx site and no cert exist for qwizz.0rk.de. Create the vhost in sites-available + symlink (matching the ssot.0rk.de convention), serve HTTP first so the ACME HTTP-01 challenge can complete, issue the cert with certbot certonly --webroot (works with any certbot install β no nginx plugin dependency), then replace with the hardened HTTPS block. options-ssl-nginx.conf and ssl-dhparams.pem already exist (verified).
6a β HTTP-only vhost β write /etc/nginx/sites-available/qwizz.0rk.de:
server {
listen 80;
server_name qwizz.0rk.de;
root /var/www/qwizz.0rk.de;
index index.php index.html;
client_max_body_size 64m;
location ^~ /.well-known/acme-challenge/ { root /var/www/qwizz.0rk.de; allow all; }
location = /xmlrpc.php { deny all; }
location = /wp-config.php { deny all; }
location ~ /\.(?!well-known) { deny all; }
location ~* /(?:uploads|wp-content|wp-includes)/.*\.php$ { deny all; }
location / { try_files $uri $uri/ /index.php?$args; }
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_read_timeout 120s;
}
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
}sudo ln -sfn /etc/nginx/sites-available/qwizz.0rk.de /etc/nginx/sites-enabled/qwizz.0rk.de
sudo nginx -t && sudo systemctl reload nginx6b β issue the cert:
sudo certbot certonly --webroot -w /var/www/qwizz.0rk.de -d qwizz.0rk.de \
--non-interactive --agree-tos -m admin@0rk.de6c β replace the vhost with the hardened HTTPS block (overwrite /etc/nginx/sites-available/qwizz.0rk.de; hardening/exact locations MUST precede the generic \.php$ handler):
server {
server_name qwizz.0rk.de;
root /var/www/qwizz.0rk.de;
index index.php index.html;
client_max_body_size 64m;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location = /xmlrpc.php { deny all; }
location = /wp-config.php { deny all; }
location ~ /\.(?!well-known) { deny all; }
location ~* /(?:uploads|wp-content|wp-includes)/.*\.php$ { deny all; }
location / { try_files $uri $uri/ /index.php?$args; }
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_read_timeout 120s;
}
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/qwizz.0rk.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/qwizz.0rk.de/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
listen 80;
server_name qwizz.0rk.de;
return 301 https://$host$request_uri;
}snippets/fastcgi-php.conf already includes fastcgi.conf (sets SCRIPT_FILENAME).
m3 SSE note (do not add now): the future /wp-json/quizwizz/v1/generate stream needs a dedicated location with fastcgi_buffering off; + gzip off; + add_header X-Accel-Buffering no;.
sudo nginx -t && sudo systemctl reload nginxCheck: test -e /etc/letsencrypt/live/qwizz.0rk.de/fullchain.pem && echo CERT_OK; curl -s -o /dev/null -w '%{http_code}' https://qwizz.0rk.de/wp-login.php β 200; β¦/xmlrpc.php β 403; curl -s -o /dev/null -w '%{http_code}' http://qwizz.0rk.de/ β 301. β SSOT-SYNC.
Phase 7 β Scaffold the plugin shell, block tooling, and test harness
One-shot phase. If a partial quizwizz dir exists from a failed run, sudo rm -rf /var/www/qwizz.0rk.de/wp-content/plugins/quizwizz first. Final file layout:
wp-content/plugins/quizwizz/
quizwizz.php # header + constants + activation/deactivation + Plugin::boot()
uninstall.php # drop tables, delete options + qw_* posts
composer.json # PSR-4 QuizWizz\ => includes/ ; dev: phpcs/wpcs/phpunit
package.json # @wordpress/scripts build + alpinejs dep
phpcs.xml.dist
phpunit.xml.dist bin/install-wp-tests.sh # from wp scaffold plugin-tests
languages/ # .pot target
includes/
Plugin.php PostTypes.php Taxonomies.php Meta.php Tables.php
Rest/QuizController.php Rest/AttemptController.php
Import/OpenTdbImporter.php Admin/ImportPage.php
src/quiz/ # create-block dynamic block (block.json, edit.js, render.php, *.scss)
assets/js/quizwizz-player.js assets/js/alpine.min.js assets/css/print.css
build/ tests/ vendor/ node_modules/ # generated (gitignore)
$WP scaffold plugin quizwizz --plugin_name="QuizWizz" --plugin_author="0rk" \
--plugin_uri="https://qwizz.0rk.de" --skip-tests
cd /var/www/qwizz.0rk.de/wp-content/plugins/quizwizz
$RUN npx --yes @wordpress/create-block@latest quiz --no-plugin --namespace quizwizz \
--variant dynamic --title "QuizWizz Quiz" --short-description "Embed a playable QuizWizz quiz." --category widgets
$WP scaffold plugin-tests quizwizz
$RUN npm install alpinejs@3.15.12
mkdir -p languagesReplace the scaffolded composer.json with:
{
"name": "quizwizz/quizwizz",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": { "php": ">=8.3" },
"require-dev": {
"squizlabs/php_codesniffer": "^3.11",
"wp-coding-standards/wpcs": "^3.1",
"phpcompatibility/phpcompatibility-wp": "^2.1",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"phpunit/phpunit": "^11.5",
"yoast/phpunit-polyfills": "^3.1"
},
"autoload": { "psr-4": { "QuizWizz\\": "includes/" } },
"config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true } },
"scripts": { "phpcs": "phpcs", "phpcbf": "phpcbf", "test": "phpunit" }
}Add a prebuild to package.json scripts so Alpine ships in assets/:
"prebuild": "mkdir -p assets/js && cp node_modules/alpinejs/dist/cdn.min.js assets/js/alpine.min.js"$RUN composer install --no-interaction
$RUN npm run build # prebuild (copy Alpine) then wp-scripts build β build/quiz/Check: test -f build/quiz/block.json && test -f assets/js/alpine.min.js && test -d vendor && echo SCAFFOLDED. β SSOT-SYNC.
Phase 8 β Implement milestone m1 (exact contracts)
All identifiers are prefixed qw_/QuizWizz\/quizwizz (Plugin Check rejects unprefixed generic names). Text domain quizwizz. Order on every write path: capability β nonce β sanitize β ESCAPE. Every output escaped (esc_html/esc_attr/wp_json_encode); every input sanitized (absint/sanitize_text_field).
8.1 quizwizz.php β header (Plugin Name: QuizWizz, Requires PHP: 8.3, Requires at least: 7.0, Version: 1.0.0, Text Domain: quizwizz), defined('ABSPATH') || exit;, constants QUIZWIZZ_FILE=__FILE__, QUIZWIZZ_PATH=plugin_dir_path(__FILE__), QUIZWIZZ_URL=plugin_dir_url(__FILE__), QUIZWIZZ_VER='1.0.0'; require QUIZWIZZ_PATH.'vendor/autoload.php'; then:
register_activation_hook(__FILE__, [QuizWizz\Plugin::class,'activate']);βactivate()callsPostTypes::register()thenTables::install()thenflush_rewrite_rules().register_deactivation_hook(__FILE__, 'flush_rewrite_rules');add_action('plugins_loaded', [QuizWizz\Plugin::class,'boot']);
8.2 QuizWizz\Plugin::boot(): void
load_plugin_textdomain('quizwizz', false, dirname(plugin_basename(QUIZWIZZ_FILE)).'/languages');add_action('init', β¦)βPostTypes::register,Taxonomies::register,Meta::register, andregister_block_type(QUIZWIZZ_PATH.'build/quiz').add_action('rest_api_init', β¦)βQuizController::register,AttemptController::register.add_action('admin_menu', [ImportPage::class,'register']),add_action('admin_post_qw_import', [ImportPage::class,'handle']).
8.3 CPTs (PostTypes::register)
qw_quiz:public=true,show_in_rest=true,rest_base='qw_quiz',menu_icon='dashicons-forms',supports=['title','editor','custom-fields'],has_archive=false,rewrite=['slug'=>'quiz'], labels βQuizzes/Quizβ.qw_question:public=false,show_ui=true,show_in_rest=true,rest_base='qw_question',menu_icon='dashicons-editor-help',supports=['title','custom-fields'],rewrite=false, labels βQuestions/Questionβ.
8.4 Taxonomies (Taxonomies::register) β all show_in_rest=true, public=true, on ['qw_quiz','qw_question']: qw_subject (hierarchical=true, slug subject), qw_difficulty (false, difficulty), qw_language (false, language), qw_tag (false, tag).
8.5 Meta (Meta::register) via register_post_meta (single=true, show_in_rest with schema, auth_callback requiring edit_posts):
qw_question:qw_typestring default'multiple';qw_choicesarray(string);qw_correct_idxinteger;qw_explanationstring;qw_sourcestring;qw_langstring default'en'.qw_quiz:qw_modestring default'practice'(practice|timed|shuffle);qw_formstring default'A';qw_langstring default'en';qw_question_idsarray(integer).
8.6 Tables (Tables::install) β global $wpdb; require_once ABSPATH.'wp-admin/includes/upgrade.php'; update_option('qw_db_version','1.0'). No custom tables needed for m1. Tables::drop() (uninstall) deletes the option.
8.7 REST β QuizController::register (quizwizz/v1):
GET /quiz/(?P<id>\d+),permission_callback => '__return_true'. 404 (WP_Error 'qw_not_found', ['status'=>404]) if missing or not publishedqw_quiz.correct_idxincluded for client grading:
{ "id":123, "title":"β¦", "mode":"practice", "form":"A", "lang":"en",
"questions":[ { "id":45, "stem":"β¦", "type":"multiple", "choices":["β¦","β¦"], "correct_idx":1, "source":"OpenTDB" } ] }Build questions from qw_question_ids, skipping ids not published qw_question.
8.9 Importer β OpenTdbImporter (network/mapping split for testability):
fetch(int $amount, ?int $category, string $difficulty): array|WP_Errorβwp_remote_get('https://opentdb.com/api.php?amount='.$amount.'&type=multiple&encode=base64'.($category?'&category='.$category:'').($difficulty!=='any'?'&difficulty='.$difficulty:''), ['timeout'=>15]);is_wp_errorβ return it; decode JSON; mapresponse_code0βresults, 1qw_no_results, 2qw_bad_param, 3|4 token βWP_Error.import(array $results, string $quizTitle): arrayβ pure. Per result: decode each fieldhtml_entity_decode(base64_decode($s), ENT_QUOTES|ENT_HTML5, 'UTF-8');$choices=[...$incorrect,$correct]; shuffle($choices); $idx=array_search($correct,$choices,true);wp_insert_post(['post_type'=>'qw_question','post_status'=>'publish','post_title'=>wp_trim_words($stem,12)]); metaqw_choices(eachsanitize_text_field),qw_correct_idx,qw_type='multiple',qw_source='OpenTDB',qw_lang='en'; setqw_subject(category) +qw_difficulty. Then oneqw_quizwithqw_question_ids,qw_mode='practice',qw_form='A'. Return['quiz_id'=>id,'question_ids'=>[β¦]].categories(): arrayβwp_remote_get('https://opentdb.com/api_category.php')βtrivia_categories[{id,name}], cached in transientqw_opentdb_categories24h;[]on error.
8.10 Admin import page β ImportPage
register():add_management_page('QuizWizz Import','QuizWizz Import','manage_options','qw-import',[self::class,'render'])(Tools menu).render():current_user_can('manage_options')gate; formmethod=post action=admin-post.php, hiddenaction=qw_import+wp_nonce_field('qw_import'); fieldsamount(number 1β50),category(select fromOpenTdbImporter::categories(), "" = any),difficulty(any|easy|medium|hard),quiz_title(text). Render success/error notice from the user transient.handle():current_user_can('manage_options')elsewp_die;check_admin_referer('qw_import'); sanitize (absint,sanitize_text_field);fetch()thenimport();set_transient('qw_import_notice_'.get_current_user_id(), β¦, 60);wp_safe_redirect(admin_url('tools.php?page=qw-import')); exit;.
8.11 Block render + Alpine player + print
src/quiz/block.json:apiVersion:3,name:"quizwizz/quiz",category:"widgets",attributes:{"quizId":{"type":"integer","default":0}},render:"file:./render.php",editorScript:"file:./index.js",supports:{"html":false}.src/quiz/edit.js:InspectorControlsβSelectControlfromuseSelect(s=>s('core').getEntityRecords('postType','qw_quiz',{per_page:100,status:'publish'})), bindsquizId; canvas showsQuizWizz: <title|"Select a quiz">.src/quiz/render.php:absint($attributes['quizId']); if not publishedqw_quizβreturn '';. Enqueue in footer with['strategy'=>'defer','in_footer'=>true]:quizwizz-alpine(QUIZWIZZ_URL.'assets/js/alpine.min.js','3.15.12'),quizwizz-player(QUIZWIZZ_URL.'assets/js/quizwizz-player.js', deps['quizwizz-alpine'],QUIZWIZZ_VER);wp_localize_script('quizwizz-player','qwData',['rest'=>esc_url_raw(rest_url('quizwizz/v1/')),'nonce'=>wp_create_nonce('qw_attempt')]); enqueue stylequizwizz-print(assets/css/print.css,QUIZWIZZ_VER, mediaprint). Output<div class="qw-quiz" x-data="qwQuiz(<?php echo absint($quizId);?>)" x-init="load()">β¦</div>: loading state; per-question stem (x-text); choice<button>s (@click="select(q.id,i)",:classselected,:aria-pressed); Prev/Next; Submit (:disabled="!answered(current.id)"); results panel (x-show="submitted": scorex-text, per-question selected-vs-correct answer key);<button class="qw-noprint" @click="window.print()">Print</button>.assets/js/quizwizz-player.js:document.addEventListener('alpine:init',()=>Alpine.data('qwQuiz',(id)=>({ id, quiz:null, answers:{}, idx:0, submitted:false, score:0, total:0, results:[], get current(){return this.quiz?.questions[this.idx]||{}}, async load(){const r=await fetch(qwData.rest+'quiz/'+id);this.quiz=await r.json();}, select(qid,i){this.answers[qid]=i;}, answered(qid){return qid in this.answers;}, submit(){this.score=0;this.total=this.quiz.questions.length;this.results=this.quiz.questions.map(q=>{const selected=this.answers[q.id];const isCorrect=selected===q.correct_idx;if(isCorrect)this.score++;return{question_id:q.id,selected_idx:selected,correct_idx:q.correct_idx,is_correct:isCorrect};});this.submitted=true;} })));(both scripts deferred; player depends on alpine)assets/css/print.css:@media print{body *{visibility:hidden}.qw-quiz,.qw-quiz *{visibility:visible}.qw-quiz{position:absolute;inset:0}.qw-noprint{display:none}@page{margin:2cm}}.
8.12 uninstall.php β defined('WP_UNINSTALL_PLUGIN')||exit;, require __DIR__.'/vendor/autoload.php';, delete_option('qw_db_version'), delete_transient('qw_opentdb_categories'), delete all qw_quiz/qw_question posts (get_posts(['numberposts'=>-1,'post_type'=>[β¦],'post_status'=>'any']) + wp_delete_post($id,true)).
Activate (triggers Tables::install):
$WP plugin activate quizwizz
$RUN bash -c "cd /var/www/qwizz.0rk.de/wp-content/plugins/quizwizz && wp i18n make-pot . languages/quizwizz.pot" || trueCheck: $WP plugin list --status=active --field=name | grep -qx quizwizz && echo ACTIVE. β SSOT-SYNC.
Phase 9 β Lint + test gates
phpcs.xml.dist: rules WordPress + WordPress-Extra; config testVersion 8.4-; config text_domain quizwizz; arg name=extensions php; exclude-pattern build/,vendor/,node_modules/,tests/; allowed prefixes qw_,QuizWizz,quizwizz.
cd /var/www/qwizz.0rk.de/wp-content/plugins/quizwizz
$RUN composer run phpcs # β 0 errors
$WP plugin install plugin-check --activate
$WP plugin check quizwizz --exclude-checks=plugin_readme # β no ERROR-level findings
$RUN bash bin/install-wp-tests.sh quizwizz_test quizwizz_user "$DB_PASS" localhost latest true
$RUN env WP_TESTS_PHPUNIT_POLYFILLS_PATH="$(pwd)/vendor/yoast/phpunit-polyfills" ./vendor/bin/phpunittrue (6th arg) = skip-DB-create (test DB exists from Phase 4). Tests under tests/ (real behavior, no network; each calls QuizWizz\Tables::install() in setUp() since the WP test framework does not run activation hooks):
test-import.php: feedOpenTdbImporter::import()a 1-item decoded fixture (verified shape{type,difficulty,category,question,correct_answer,incorrect_answers[]}); assert oneqw_question+ oneqw_quiz, andqw_choices[qw_correct_idx]equals the correct answer.test-rest.php: create quiz+questions;GET /quizwizz/v1/quiz/{id}200 withcorrect_idx; missing id β 404.
Check: phpcs 0 errors, Plugin Check no ERRORs, phpunit OK. β SSOT-SYNC.
Phase 10 β Final SSOT reconciliation (m1)
Confirm every Progress row is β
(or π΄ + note for anything deferred), append a short βOutcomeβ line under the Progress table (date, that the m1 quiz is live + playable at https://qwizz.0rk.de, credentials in /root/quizwizz-credentials.txt β value NOT inlined), then final build:
cd /home/dv/quartz-ssot && npm run quartz buildCheck: https://ssot.0rk.de/projects/quizwizz/plan shows all phases β
. β SSOT-SYNC (final).
Phase 11 β React Settings Page
Initialize a top-level React-based settings page (src/settings/) accessible via a new submenu under the βQuizWizzβ CPT. This satisfies the βBackend perfectionβ requirement.
11.1 Register an admin page options-general.php?page=quizwizz-settings via add_submenu_page.
11.2 Build a @wordpress/element component utilizing @wordpress/components (Panel, TextControl, ToggleControl, SelectControl) to manage plugin-wide options (e.g., OpenRouter API Key, default mode, theme colors).
11.3 Connect the React app to WordPressβs native Settings API or a dedicated REST endpoint using @wordpress/api-fetch to seamlessly persist data.
Check: Navigating to Settings -> QuizWizz renders a modern React interface. β SSOT-SYNC.
Phase 12 β Data Views Admin
Implement modern WordPress Data Views to replace the classic WP List Table for qw_quiz and qw_question, offering a sleek, filterable, and lightning-fast βBackend perfectionβ management experience.
12.1 Intercept the edit.php?post_type=qw_quiz and qw_question screens using @wordpress/dataviews.
12.2 Map REST API records (wp.data.select('core').getEntityRecords) to Data Views fields, enabling quick filtering by qw_difficulty, qw_subject, and qw_mode.
12.3 Add inline quick actions to directly interact with or edit quizzes/questions without leaving the list view.
Check: The Quizzes and Questions admin menus load the new interactive Data Views UI. β SSOT-SYNC.
Phase 13 β OpenRouter Gateway
Establish the secure backend conduit for LLM communication, which powers the upcoming Chat-bubble wizard.
13.1 Create includes/Ai/OpenRouterClient.php.
13.2 Read the OPENROUTER_API_KEY from environment variables (fallback to the value managed in the React Settings page).
13.3 Implement POST https://openrouter.ai/api/v1/chat/completions via wp_remote_post, handling JSON serialization, timeouts, and error gracefully.
Check: wp eval "QuizWizz\Ai\OpenRouterClient::test();" successfully hits the API and returns an LLM response. β SSOT-SYNC.
Phase 14 β Chat-bubble Wizard (Quiz Builder)
Construct the highly anticipated βQuiz Builderβ using an interactive AI Chat-bubble interface on the frontend/backend. This enables rapid, generative quiz creation (Front & Backend Perfection).
14.1 Develop the src/wizard/ React application, featuring a sleek, fixed chat widget.
14.2 Expose a secure POST /quizwizz/v1/generate REST endpoint that receives user prompts (e.g., βMake a hard quiz about Kubernetesβ), securely routes it through OpenRouterClient.php, and streams or returns the generated JSON schema.
14.3 Automatically parse the LLM JSON response to natively generate qw_question posts and group them into a new qw_quiz using the OpenTdbImporterβs proven logic pattern.
Check: A user can type a prompt into the wizard and a full quiz is instantly generated and playable. β SSOT-SYNC.
Phase 15 β Final SSOT reconciliation (m2)
Confirm all Phase 11-14 rows are β . Publish the m2 completion state.
Check: https://ssot.0rk.de/projects/quizwizz/plan shows m2 phases as complete. β SSOT-SYNC (final).
Phase 16 β Craft PDF Page (mPDF)
Implement mPDF to allow educators/users to craft a PDF version of any quiz.
16.1 Install mpdf/mpdf via Composer.
16.2 Create a /quizwizz/v1/pdf REST endpoint that uses mPDF to output the quiz.
16.3 Provide a [quizwizz_craft] shortcode and create the /craft/ WP page to invoke this tool.
Check: Visiting /craft/ and triggering a download serves a beautiful PDF of the quiz. β SSOT-SYNC.
Critical files & anchors
/home/dv/quartz-ssot/content/projects/quizwizz/plan.mdβ Phase 0 SSOT doc;titleemoji-free, mermaid edge labels double-quoted, Progress table updated every phase; no secrets./etc/nginx/sites-available/qwizz.0rk.de(+ symlink insites-enabled) β new vhost; HTTP-first for ACME, then HTTPS block; hardening locations before~ \.php$. Never editquiz.0rk.de.wp-content/plugins/quizwizz/quizwizz.phpβ header (Requires PHP: 8.3),ABSPATHguard,QUIZWIZZ_*constants, autoload,activate()βregister CPTs+Tables::install+flush,plugins_loadedβPlugin::boot.wp-content/plugins/quizwizz/includes/Tables.phpβdbDeltaschemas; whitespace-sensitive (PRIMARY KEY (id)two spaces, lowercase types).wp-content/plugins/quizwizz/src/quiz/render.php+assets/js/quizwizz-player.js+includes/Rest/*β block contract (quizIdint), deferred enqueue +qwData, AlpineqwQuiz, namespacequizwizz/v1,quiz/{id}includescorrect_idx.
Verification
Build/lint/test proofs are inline per phase; this is the end-to-end pass. wp runs from anywhere (path in $WP); phpunit/phpcs from the plugin dir.
- Stack:
php -vβ8.4;systemctl is-active php8.4-fpm nginx mariadbβallactive;curl -s -o /dev/null -w '%{http_code}' https://qwizz.0rk.de/wp-login.phpβ200;β¦/xmlrpc.phpβ403;http://qwizz.0rk.de/β301. Separate project intact:curl -s -o /dev/null -w '%{http_code}' https://quiz.0rk.de/β200. - Plugin + schema:
$WP plugin list --status=active --field=nameincludesquizwizz;$WP post-type list --field=name | grep -qx qw_quiz. - Import (new behavior): Tools β QuizWizz Import β amount 5, difficulty easy β submit; notice βImported 5 questionsβ.
$WP post list --post_type=qw_question --format=countββ₯5;QID=$($WP post list --post_type=qw_quiz --field=ID --posts_per_page=1). - REST contract:
curl -s https://qwizz.0rk.de/wp-json/quizwizz/v1/quiz/$QIDβ JSON withquestions[].choicesandcorrect_idx;curl -s -o /dev/null -w '%{http_code}' β¦/quiz/999999β404. - Play + print (new behavior, browser tool):
CONTENT=$(printf '<!-- wp:quizwizz/quiz {"quizId":%d} /-->' "$QID"); PAGEID=$($WP post create --post_type=page --post_status=publish --post_title=Play --post_content="$CONTENT" --porcelain). Openhttps://qwizz.0rk.de/?page_id=$PAGEIDin thebrowsertool:tab.observe()shows a question + choices; click through all choices; click Submit; assert a visible score (e.g. β3 / 5β) + per-question answer key; invoke print and screenshot β only.qw-quizvisible. - Gates:
$RUN composer run phpcsβ0 errors;$WP plugin check quizwizz --exclude-checks=plugin_readmeβno ERROR rows;$RUN env WP_TESTS_PHPUNIT_POLYFILLS_PATH="$(pwd)/vendor/yoast/phpunit-polyfills" ./vendor/bin/phpunitβOK. - SSOT:
https://ssot.0rk.de/projects/quizwizz/planshows the Progress table with every phase β .
Assumptions & contingencies
quiz.0rk.deis a separate live project β never created, edited, backed up, or deleted here. Onlyqwizz.0rk.deis provisioned.- DNS
qwizz.0rk.deβ this host (173.212.213.72) is verified, socertbot certonly --webrootover port 80 will validate. If issuance fails (LE rate limit / port 80 blocked): retry later, or use DNS-01 (certbot certonly --manual --preferred-challenges dns -d qwizz.0rk.de) and add a TXT record; the HTTP-only vhost from 6a stays serving until the cert lands. - Sury serves php8.4 for bookworm β
unverified(repo not addable in read-only mode; standard for Sury). Ifapt-get install php8.4-fpm404s: fall back to Debian-native PHP 8.2 β replace every8.4β8.2, socket/run/php/php8.2-fpm.sock, phpcstestVersion 8.2-, composer"php":">=8.2", headerRequires PHP: 8.2. WP 7.0 (min 7.4) is satisfied either way. - Node 24 vs
@wordpress/scriptsβ ifnpm run buildfails an engine check, re-run withnpm_config_engine_strict=false; if a Node API breaks the build, install Node 20 LTS vianvmand build under it (runtime PHP unaffected). wp plugin checknot registered by the plugin βwp package install 10up/plugin-check-clithen re-run, or use admin Tools β Plugin Check.install-wp-tests.shsvn fetch fails (subversioninstalled in Phase 1, mitigating the usual cause) βcomposer require --dev wp-phpunit/wp-phpunitand pointtests/bootstrap.phpatWP_PHPUNIT__DIR; keep the same test files.- OpenTDB may rate-limit / return
response_codeβ 0; the importer surfaces this as an admin notice and tests use a local fixture (no network in CI). - Admin identity
qwadmin/admin@0rk.de, password generated to/root/quizwizz-credentials.txt; DB prefixwp_; collationutf8mb4_unicode_ci. Override by editing Phase 4/5 before running. - Rollback (any infra phase fails irrecoverably):
sudo rm -f /etc/nginx/sites-enabled/qwizz.0rk.de && sudo systemctl reload nginxto unpublish the vhost; optionallysudo rm -rf /var/www/qwizz.0rk.deand drop thequizwizz/quizwizz_testDBs.quiz.0rk.deand all other vhosts are unaffected. Record π΄ + cause in the SSOT Progress row.