From 2324c83c144af83af909ec79129ce23423564f8a Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Wed, 20 May 2026 19:55:15 -0400 Subject: [PATCH] feat: add WordPress multisite (subdirectory) network support (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #23. Ships multisite as an opt-in compose profile alongside the existing single-site setup — `docker compose --profile multisite up multisite-installer` converts the install into a subdirectory network, writes the network constants via `wp config set`, replaces .htaccess, and creates a sample second site. Subdomain mode is deliberately not wired up: it needs wildcard DNS that doesn't work generically in Docker. The script refuses to silently produce a broken setup; docs/multisite/README.md documents the manual flip with the wildcard-DNS caveats called out. The mu-plugin (`mu-plugins/flavian-multisite.php`) early-returns on single-site installs, so it's safe to leave in place. When multisite is active it adds a cached `get_sites()` helper, a network admin dashboard widget, a per-site notice surfacing the network to super admins, and a `[flavian_network_sites]` shortcode for cross-site nav. Agent layer: `wp-environment-manager` gains a § 8 covering site CRUD, super admin management, network-wide activation, multisite-aware code patterns, and a troubleshooting table. No new agent name to keep multisite ops as a single source of truth. PHPCS verified against `WordPress-Extra,WordPress-Docs` (1 file, 0 errors after a phpcbf pass for `=>` alignment). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agents/wp-environment-manager.md | 98 +++++++++ .env.example | 8 + CLAUDE.md | 12 ++ docker-compose.yml | 33 +++ docs/multisite/README.md | 104 ++++++++++ mu-plugins/flavian-multisite.php | 188 +++++++++++++++++ scripts/wordpress-install/setup-multisite.sh | 208 +++++++++++++++++++ 7 files changed, 651 insertions(+) create mode 100644 docs/multisite/README.md create mode 100644 mu-plugins/flavian-multisite.php create mode 100644 scripts/wordpress-install/setup-multisite.sh diff --git a/.claude/agents/wp-environment-manager.md b/.claude/agents/wp-environment-manager.md index 53a86ba..4e53012 100644 --- a/.claude/agents/wp-environment-manager.md +++ b/.claude/agents/wp-environment-manager.md @@ -162,6 +162,85 @@ docker compose exec wordpress wp option update show_on_front page --allow-root docker compose exec wordpress wp option update page_on_front [page_id] --allow-root ``` +### 8. Multisite Network Operations + +The repo ships a one-shot installer that converts a working single-site install into a subdirectory network. **Subdomain mode is intentionally not wired up** — it needs wildcard DNS that's awkward in generic Docker. Document the manual flip and stop, don't try to hand-roll it. + +**First-time setup** (idempotent — re-runs harmlessly): + +```bash +# Compose profile (preferred) +docker compose --profile multisite up multisite-installer + +# Or run the script directly on the host +./scripts/wordpress-install/setup-multisite.sh \ + --network-title "Flavian Network" \ + --second-site-slug site2 \ + --second-site-title "Site Two" +``` + +What it does: +1. Adds `WP_ALLOW_MULTISITE=true` to wp-config.php. +2. Runs `wp core multisite-convert` (subdirectory mode, base `/`). +3. Writes `MULTISITE`, `SUBDOMAIN_INSTALL=false`, `DOMAIN_CURRENT_SITE`, `PATH_CURRENT_SITE='/'`, `SITE_ID_CURRENT_SITE=1`, `BLOG_ID_CURRENT_SITE=1` via `wp config set`. +4. Replaces `.htaccess` with the multisite rewrite block. +5. Creates a sample second site (skip with `--no-second-site`). +6. Promotes the WP admin (user 1) to super admin. + +**Site management:** + +```bash +# List sites +docker compose exec wordpress wp site list --allow-root + +# Create a new site (subdirectory) +docker compose exec wordpress wp site create \ + --slug=marketing --title="Marketing" --email=admin@localhost --allow-root + +# Archive / deactivate / delete +docker compose exec wordpress wp site archive 2 --allow-root +docker compose exec wordpress wp site deactivate 2 --allow-root +docker compose exec wordpress wp site delete 2 --yes --allow-root + +# Inspect a single site (every wp command supports --url=) +docker compose exec wordpress wp post list --url=http://localhost:8080/site2/ --allow-root +``` + +**Super admin management:** + +```bash +docker compose exec wordpress wp super-admin list --allow-root +docker compose exec wordpress wp super-admin add jdoe --allow-root +docker compose exec wordpress wp super-admin remove jdoe --allow-root +``` + +**Network-wide plugin/theme activation** — required for code to be available on every sub-site: + +```bash +docker compose exec wordpress wp plugin activate woocommerce --network --allow-root +docker compose exec wordpress wp theme enable flavian-shop --network --allow-root +``` + +`--network` activates a plugin for every site; `theme enable --network` only makes a theme available — sub-sites still have to switch to it individually. + +**Multisite-aware code patterns** to recommend when reviewing themes or plugins: + +- Guard cross-site logic with `if ( is_multisite() ) { ... }`. +- When iterating sites, use `get_sites()` (not the deprecated `wp_get_sites()`) and always pair `switch_to_blog( $id )` with `restore_current_blog()`. +- Cache cross-site queries with `set_site_transient()` — `mu-plugins/flavian-multisite.php` already does this for the network site list under the `flavian_multisite_sites` key. +- For URLs that should resolve on the right site, prefer `network_home_url()` / `get_admin_url( $blog_id )` over hand-built strings. + +**Mu-plugin helpers shipped in this repo** (`mu-plugins/flavian-multisite.php`): + +| Helper | Purpose | +|---|---| +| `Flavian\\Multisite\\get_network_sites_cached()` | `get_sites()` with a 5-minute transient cache. Auto-invalidated on `wp_initialize_site`, `wp_delete_site`, `wp_update_site`. | +| Network admin dashboard widget | Lists every site with links into each one's admin. | +| Per-site dashboard notice | Surfaces the network admin link to super admins viewing a single site. | +| `[flavian_network_sites]` shortcode | Lists sister sites; `exclude_current="false"` to include the current site. | + +The mu-plugin is gated on `is_multisite()` and is a no-op on single-site deployments — safe to leave in place. + ## Workflow: Full Environment Setup ``` @@ -179,6 +258,20 @@ docker compose exec wordpress wp option update page_on_front [page_id] --allow-r 12. Report environment status ``` +## Workflow: First-Time Multisite Setup + +``` +1. Verify WordPress is installed (single-site) and reachable on localhost:8080. +2. docker compose --profile multisite up multisite-installer +3. Visit http://localhost:8080/wp-admin/network/ and confirm the network + dashboard renders. +4. Confirm sub-site loads: http://localhost:8080/site2/ +5. wp site list — should return at least 2 rows. +6. wp super-admin list — should include the WP admin. +7. If a theme or plugin needs to be available everywhere, activate it with + --network. +``` + ## Workflow: Theme Testing Setup ``` @@ -225,3 +318,8 @@ docker compose exec wordpress wp option update page_on_front [page_id] --allow-r - Theme activation fails → Check style.css header, check for PHP errors - Database error → Check DB container logs, verify credentials in wp-config.php - Permission errors → Check volume mount permissions, use `--allow-root` +- Multisite: `wp_initialize_site` errors during site create → DB tables didn't exist; re-run `wp core multisite-convert` (idempotent) or run the installer +- Multisite: sub-site returns 404 → `.htaccess` missing the multisite rewrite block; re-run the installer or use `--skip-htaccess` and write it by hand +- Multisite: "Sorry, you are not allowed to access this page" on `/wp-admin/network/` → user 1 isn't a super admin; `wp super-admin add ` +- Multisite: plugin works on main site but not on sub-sites → wasn't network-activated; `wp plugin activate --network` +- Multisite: subdomain mode requested → DO NOT silently switch; explain that subdomain mode needs wildcard DNS (`*.localhost`, dnsmasq, or hosts entries) and that this repo ships subdirectory mode only diff --git a/.env.example b/.env.example index db5a874..19fae52 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,11 @@ WC_DEFAULT_COUNTRY=US:CA HEADLESS_FRONTEND_URL=http://localhost:3000 HEADLESS_INSTALL_JWT=true HEADLESS_INSTALL_BLOCKS=true + +# Multisite installer (compose profile: multisite) +# Run: docker compose --profile multisite up multisite-installer +# Subdirectory mode only. See docs/multisite/README.md for subdomain notes. +MS_NETWORK_TITLE=Flavian Network +MS_CREATE_SECOND_SITE=true +MS_SECOND_SITE_SLUG=site2 +MS_SECOND_SITE_TITLE=Site Two diff --git a/CLAUDE.md b/CLAUDE.md index 5ecd39c..55ac355 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -548,6 +548,18 @@ gh auth status # Check authentication ``` Configs live in `.claude/config/deployment/.yml` (gitignored). Templates: `*.example.yml`. +**Multisite network (subdirectory mode):** +```bash +# Convert the single-site install into a subdirectory network + sample sub-site +docker compose --profile multisite up multisite-installer + +# Or run the script directly +./scripts/wordpress-install/setup-multisite.sh --network-title "My Network" +``` +Mu-plugin: `mu-plugins/flavian-multisite.php` (no-op on single-site installs). +Agent: `wp-environment-manager` § 8 covers site CRUD, super admins, and network activation. +Full guide: `docs/multisite/README.md`. + **Headless WordPress (decoupled frontends):** ```bash # One-shot install: WPGraphQL + CORS + preview secret diff --git a/docker-compose.yml b/docker-compose.yml index 14b3a45..5d0e7ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -126,6 +126,39 @@ services: [ "$$HEADLESS_INSTALL_BLOCKS" = "false" ] && ARGS="$$ARGS --no-blocks" bash /scripts/setup-headless.sh $$ARGS + # One-shot multisite installer. + # + # Converts the install into a subdirectory multisite network and creates + # a sample sub-site so the network admin has something to look at: + # + # docker compose --profile multisite up multisite-installer + # + # Re-running is safe — every step is idempotent. + multisite-installer: + image: docker:cli + container_name: flavian-multisite-installer + profiles: ["multisite"] + depends_on: + wordpress: + condition: service_healthy + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./scripts/wordpress-install:/scripts:ro + working_dir: /scripts + environment: + MS_NETWORK_TITLE: ${MS_NETWORK_TITLE:-Flavian Network} + MS_SECOND_SITE_SLUG: ${MS_SECOND_SITE_SLUG:-site2} + MS_SECOND_SITE_TITLE: ${MS_SECOND_SITE_TITLE:-Site Two} + MS_CREATE_SECOND_SITE: ${MS_CREATE_SECOND_SITE:-true} + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + apk add --no-cache bash docker-cli-compose >/dev/null + ARGS="--network-title \"$$MS_NETWORK_TITLE\" --second-site-slug $$MS_SECOND_SITE_SLUG --second-site-title \"$$MS_SECOND_SITE_TITLE\"" + [ "$$MS_CREATE_SECOND_SITE" = "false" ] && ARGS="--network-title \"$$MS_NETWORK_TITLE\" --no-second-site" + eval bash /scripts/setup-multisite.sh $$ARGS + # Optional: phpMyAdmin for database management phpmyadmin: image: phpmyadmin:latest diff --git a/docs/multisite/README.md b/docs/multisite/README.md new file mode 100644 index 0000000..a0b6aa3 --- /dev/null +++ b/docs/multisite/README.md @@ -0,0 +1,104 @@ +# WordPress Multisite + +The repo ships an opt-in multisite installer that converts the local WordPress install into a **subdirectory** network in one command. Subdomain mode is intentionally out of scope for the MVP — see [Why subdomain mode isn't shipped](#why-subdomain-mode-isnt-shipped). + +## What's in the box + +| Piece | Path | Purpose | +|---|---|---| +| Installer script | `scripts/wordpress-install/setup-multisite.sh` | Runs `wp core multisite-convert`, writes the network constants via `wp config set`, replaces `.htaccess`, optionally creates a sample second site, and promotes user 1 to super admin. | +| Compose profile | `docker-compose.yml` → `multisite-installer` | Wraps the installer in a one-shot container. Run with `docker compose --profile multisite up multisite-installer`. | +| Mu-plugin | `mu-plugins/flavian-multisite.php` | Cached site list, network admin dashboard widget, per-site notice for super admins, `[flavian_network_sites]` shortcode. Gated on `is_multisite()`. | +| Agent | `.claude/agents/wp-environment-manager.md` § 8 | Multisite operations: install, site CRUD, super admin mgmt, network-wide activation, code patterns. | + +## Five-minute setup + +```bash +# 1. Bring up WordPress (if not already running) +docker compose up -d + +# 2. Convert to multisite (subdirectory mode + sample second site) +docker compose --profile multisite up multisite-installer + +# 3. Visit the network admin +open http://localhost:8080/wp-admin/network/ +``` + +That's it. The installer is idempotent — re-running detects an existing network and bails out cleanly. + +## Customising the installer + +Environment variables read by the compose profile: + +| Variable | Default | Effect | +|---|---|---| +| `MS_NETWORK_TITLE` | `Flavian Network` | Title shown on the network admin. | +| `MS_CREATE_SECOND_SITE` | `true` | Create the sample sub-site. | +| `MS_SECOND_SITE_SLUG` | `site2` | Slug for the sample sub-site → `/site2/`. | +| `MS_SECOND_SITE_TITLE` | `Site Two` | Title for the sample sub-site. | + +Or call the script directly with flags: + +```bash +./scripts/wordpress-install/setup-multisite.sh \ + --network-title "Acme Holdings" \ + --second-site-slug press \ + --second-site-title "Acme Press" +``` + +## Day-to-day site operations + +```bash +# List sites +docker compose exec wordpress wp site list --allow-root + +# Create another site +docker compose exec wordpress wp site create \ + --slug=marketing --title=Marketing --email=admin@localhost --allow-root + +# Run wp-cli against a specific site +docker compose exec wordpress wp post list --url=http://localhost:8080/site2/ --allow-root + +# Network-wide plugin activation (otherwise sub-sites can't see the plugin) +docker compose exec wordpress wp plugin activate woocommerce --network --allow-root + +# Make a theme available to every sub-site (each site still has to switch to it) +docker compose exec wordpress wp theme enable flavian-shop --network --allow-root + +# Super admins +docker compose exec wordpress wp super-admin add --allow-root +``` + +## The mu-plugin + +`mu-plugins/flavian-multisite.php` is a no-op on single-site installs (early `return` on `is_multisite()`). When multisite is active, it adds: + +- `Flavian\Multisite\get_network_sites_cached()` — `get_sites()` wrapped in a 5-minute transient. Invalidated on `wp_initialize_site`, `wp_update_site`, `wp_delete_site`. +- A **Network sites** dashboard widget on the network admin. +- A dashboard notice on per-site admin screens linking back to the network admin (super admins only). +- A `[flavian_network_sites]` shortcode for embedding a cross-site nav. `exclude_current="false"` to include the current site. + +## Why subdomain mode isn't shipped + +Subdomain mode (e.g. `site2.localhost`) needs the request to actually arrive at WordPress with the right `Host` header. Locally that means one of: + +- `*.localhost` resolution. Modern Chrome handles `*.localhost` natively, but Safari and curl don't by default. +- A `/etc/hosts` entry per sub-site (`127.0.0.1 site2.localhost`). Easy but unmaintainable as you create more sites. +- `dnsmasq` configured to wildcard-resolve `.localhost`. + +The script bails out on `--subdomain` rather than silently producing a broken setup. If you need subdomain mode, set up wildcard DNS first, then in `wp-config.php`: + +```php +define( 'SUBDOMAIN_INSTALL', true ); // was false +define( 'DOMAIN_CURRENT_SITE', 'localhost' ); +``` + +…and run `wp rewrite flush --hard`. The repo's mu-plugin doesn't care which mode you're in. + +## Troubleshooting + +See the **Error Recovery** section of `.claude/agents/wp-environment-manager.md` for the canonical list. The three issues that cover ~90% of failures: + +1. **`/wp-admin/network/` returns "not allowed"** → user 1 isn't a super admin yet. `wp super-admin add `. +2. **Sub-site URLs return 404** → the multisite `.htaccess` rewrite block is missing. Re-run the installer (it's idempotent) or run it with `--skip-htaccess` and edit the file by hand. +3. **Plugin works on main site, missing on sub-sites** → wasn't network-activated. Re-run `wp plugin activate --network`. diff --git a/mu-plugins/flavian-multisite.php b/mu-plugins/flavian-multisite.php new file mode 100644 index 0000000..2f6b464 --- /dev/null +++ b/mu-plugins/flavian-multisite.php @@ -0,0 +1,188 @@ + + */ +function get_network_sites_cached(): array { + $cached = get_site_transient( SITES_CACHE_KEY ); + if ( is_array( $cached ) ) { + return $cached; + } + + $sites = get_sites( + array( + 'number' => 100, + 'orderby' => 'path', + ) + ); + + set_site_transient( SITES_CACHE_KEY, $sites, SITES_CACHE_TTL ); + return $sites; +} + +/** + * Invalidate the cached site list whenever a site is created or deleted. + * + * @return void + */ +function flush_sites_cache(): void { + delete_site_transient( SITES_CACHE_KEY ); +} +add_action( 'wp_initialize_site', __NAMESPACE__ . '\\flush_sites_cache' ); +add_action( 'wp_delete_site', __NAMESPACE__ . '\\flush_sites_cache' ); +add_action( 'wp_update_site', __NAMESPACE__ . '\\flush_sites_cache' ); + +/** + * Register the "Network Sites" dashboard widget on the network admin. + * + * @return void + */ +function register_network_dashboard_widget(): void { + wp_add_dashboard_widget( + 'flavian_network_sites', + __( 'Network sites', 'flavian' ), + __NAMESPACE__ . '\\render_network_dashboard_widget' + ); +} +add_action( 'wp_network_dashboard_setup', __NAMESPACE__ . '\\register_network_dashboard_widget' ); + +/** + * Render the body of the network sites dashboard widget. + * + * @return void + */ +function render_network_dashboard_widget(): void { + $sites = get_network_sites_cached(); + if ( empty( $sites ) ) { + echo '

' . esc_html__( 'No sites in this network yet.', 'flavian' ) . '

'; + return; + } + + echo '
    '; + foreach ( $sites as $site ) { + $blog_id = (int) $site->blog_id; + $details = get_blog_details( $blog_id ); + if ( ! $details ) { + continue; + } + printf( + '
  • %2$s%3$s
  • ', + esc_url( get_admin_url( $blog_id ) ), + esc_html( $details->blogname ), + esc_html( $details->siteurl ) + ); + } + echo '
'; +} + +/** + * Show a one-line notice on per-site admin screens linking back to the network. + * + * Helps editors who are managing multiple sub-sites realise they have + * network-admin privileges available. + * + * @return void + */ +function admin_notice_network_link(): void { + if ( ! is_super_admin() ) { + return; + } + if ( is_network_admin() ) { + return; + } + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen || 'dashboard' !== $screen->id ) { + return; + } + $count = count( get_network_sites_cached() ); + printf( + '

%1$s %3$s

', + esc_html( + sprintf( + /* translators: %d: site count */ + _n( 'This is one of %d networked site.', 'This is one of %d networked sites.', $count, 'flavian' ), + $count + ) + ), + esc_url( network_admin_url() ), + esc_html__( 'Open Network Admin', 'flavian' ) + ); +} +add_action( 'admin_notices', __NAMESPACE__ . '\\admin_notice_network_link' ); + +/** + * Expose a `flavian_network_sites` shortcode that lists sister sites. + * + * Useful in the network's main site footer or any place a theme wants a + * cross-site nav without hand-rolling the query. + * + * @param array $atts Shortcode attributes. + * @return string + */ +function shortcode_network_sites( $atts ): string { + $atts = shortcode_atts( + array( + 'exclude_current' => 'true', + ), + $atts, + 'flavian_network_sites' + ); + + $current_blog_id = get_current_blog_id(); + $exclude_current = 'true' === strtolower( (string) $atts['exclude_current'] ); + $sites = get_network_sites_cached(); + $html = '
    '; + $rendered_any = false; + + foreach ( $sites as $site ) { + $blog_id = (int) $site->blog_id; + if ( $exclude_current && $blog_id === $current_blog_id ) { + continue; + } + $details = get_blog_details( $blog_id ); + if ( ! $details ) { + continue; + } + $rendered_any = true; + $html .= sprintf( + '
  • %2$s
  • ', + esc_url( $details->siteurl ), + esc_html( $details->blogname ) + ); + } + $html .= '
'; + + return $rendered_any ? $html : ''; +} +add_shortcode( 'flavian_network_sites', __NAMESPACE__ . '\\shortcode_network_sites' ); diff --git a/scripts/wordpress-install/setup-multisite.sh b/scripts/wordpress-install/setup-multisite.sh new file mode 100644 index 0000000..2279880 --- /dev/null +++ b/scripts/wordpress-install/setup-multisite.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# Convert the local WordPress install to a multisite network. +# +# What it does, in order: +# 1. Verifies WordPress is installed and not already multisite. +# 2. Adds WP_ALLOW_MULTISITE to wp-config.php. +# 3. Runs `wp core multisite-convert` (subdirectory mode by default). +# 4. Patches in the network constants WordPress prints on the Network +# Setup screen (MULTISITE, SUBDOMAIN_INSTALL, DOMAIN_CURRENT_SITE, +# PATH_CURRENT_SITE, SITE_ID_CURRENT_SITE, BLOG_ID_CURRENT_SITE). +# 5. Writes a multisite-aware .htaccess. +# 6. Optionally creates a sample second site. +# 7. Promotes the WP admin to a super admin. +# +# Subdomain mode is intentionally not wired up here — it requires wildcard +# DNS / *.localhost which is awkward to ship in a generic Docker setup. +# Document the manual flip in docs/multisite/README.md for now. +# +# Usage: +# ./scripts/wordpress-install/setup-multisite.sh [options] +# +# Options: +# --network-title TITLE Network title (default: "Flavian Network") +# --no-second-site Skip creating the sample sub-site +# --second-site-slug SLUG Slug for the sample sub-site (default: "site2") +# --second-site-title T Title for the sample sub-site (default: "Site Two") +# --skip-htaccess Don't touch .htaccess +# +# Requires: docker compose, the project's WordPress container running. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +NETWORK_TITLE="${MS_NETWORK_TITLE:-Flavian Network}" +CREATE_SECOND_SITE=true +SECOND_SITE_SLUG="${MS_SECOND_SITE_SLUG:-site2}" +SECOND_SITE_TITLE="${MS_SECOND_SITE_TITLE:-Site Two}" +SKIP_HTACCESS=false + +usage() { + cat <&2; usage; exit 2 ;; + esac +done + +log() { printf '\n[setup-multisite] %s\n' "$*"; } + +WP_CONTAINER="${WP_CONTAINER:-flavian-wp}" +if [[ -f /.dockerenv ]] || [[ "${WP_USE_DOCKER_EXEC:-}" == "true" ]]; then + wp_in_container() { + docker exec -i "$WP_CONTAINER" wp "$@" --allow-root + } + raw_in_container() { + docker exec -i "$WP_CONTAINER" "$@" + } +else + wp_in_container() { + docker compose exec -T wordpress wp "$@" --allow-root + } + raw_in_container() { + docker compose exec -T wordpress "$@" + } +fi + +# 0. Prerequisites ----------------------------------------------------------- +if ! command -v docker >/dev/null 2>&1; then + echo "[ERROR] docker not found in PATH" >&2 + exit 1 +fi + +if [[ ! -f /.dockerenv ]]; then + if ! docker compose ps --status running --services 2>/dev/null | grep -q '^wordpress$'; then + log "WordPress container not running — starting it" + docker compose up -d wordpress db + for _ in $(seq 1 30); do + if docker compose exec -T wordpress curl -sf http://localhost/ >/dev/null 2>&1; then + break + fi + sleep 2 + done + fi +fi + +if ! wp_in_container core is-installed >/dev/null 2>&1; then + echo "[ERROR] WordPress is not installed yet. Run the WP install step first" >&2 + exit 1 +fi + +if wp_in_container core is-installed --network >/dev/null 2>&1; then + log "Network already configured — nothing to do" + log " Network admin: http://localhost:8080/wp-admin/network/" + exit 0 +fi + +# 1. Allow multisite --------------------------------------------------------- +if ! wp_in_container config has WP_ALLOW_MULTISITE --type=constant >/dev/null 2>&1; then + log "Adding WP_ALLOW_MULTISITE to wp-config.php" + wp_in_container config set WP_ALLOW_MULTISITE true --raw --type=constant >/dev/null +fi + +# 2. Convert to multisite ---------------------------------------------------- +log "Converting to multisite (subdirectory mode)" +wp_in_container core multisite-convert \ + --title="$NETWORK_TITLE" \ + --base=/ \ + >/dev/null + +# 3. Write the network constants wp-cli omits -------------------------------- +# `wp core multisite-convert` writes them on recent wp-cli versions, but on +# older builds it just prints them to stdout. Use `wp config set` for each +# so the result is deterministic regardless of the wp-cli version. +log "Ensuring network constants exist in wp-config.php" +SITE_HOST="$(wp_in_container option get siteurl | sed -E 's|^https?://||; s|/.*$||' | tr -d '\r')" +[[ -z "$SITE_HOST" ]] && SITE_HOST="localhost:8080" + +set_config_const() { + local name="$1" value="$2" raw="${3:-}" + if wp_in_container config has "$name" --type=constant >/dev/null 2>&1; then + return + fi + if [[ -n "$raw" ]]; then + wp_in_container config set "$name" "$value" --raw --type=constant >/dev/null + else + wp_in_container config set "$name" "$value" --type=constant >/dev/null + fi +} + +set_config_const MULTISITE true raw +set_config_const SUBDOMAIN_INSTALL false raw +set_config_const DOMAIN_CURRENT_SITE "$SITE_HOST" +set_config_const PATH_CURRENT_SITE '/' +set_config_const SITE_ID_CURRENT_SITE 1 raw +set_config_const BLOG_ID_CURRENT_SITE 1 raw + +# 4. .htaccess --------------------------------------------------------------- +if ! $SKIP_HTACCESS; then + log "Writing multisite .htaccess" + raw_in_container tee /var/www/html/.htaccess >/dev/null <<'HTACCESS' +# BEGIN WordPress Multisite +RewriteEngine On +RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] +RewriteBase / +RewriteRule ^index\.php$ - [L] + +# add a trailing slash to /wp-admin +RewriteRule ^([_0-9a-zA-Z-]+/)?wp-admin$ $1wp-admin/ [R=301,L] + +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] +RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) $2 [L] +RewriteRule ^([_0-9a-zA-Z-]+/)?(.*\.php)$ $2 [L] +RewriteRule . index.php [L] +# END WordPress Multisite +HTACCESS + raw_in_container chown www-data:www-data /var/www/html/.htaccess +fi + +# 5. Optional second site ---------------------------------------------------- +if $CREATE_SECOND_SITE; then + if wp_in_container site list --field=url 2>/dev/null | grep -q "/$SECOND_SITE_SLUG/"; then + log "Sub-site '$SECOND_SITE_SLUG' already exists" + else + log "Creating sub-site: $SECOND_SITE_TITLE (/$SECOND_SITE_SLUG/)" + wp_in_container site create \ + --slug="$SECOND_SITE_SLUG" \ + --title="$SECOND_SITE_TITLE" \ + --email="$(wp_in_container user get 1 --field=user_email)" \ + >/dev/null + fi +fi + +# 6. Super admin ------------------------------------------------------------- +ADMIN_LOGIN="$(wp_in_container user get 1 --field=user_login | tr -d '\r')" +if [[ -n "$ADMIN_LOGIN" ]]; then + wp_in_container super-admin add "$ADMIN_LOGIN" >/dev/null 2>&1 || true + log "Promoted $ADMIN_LOGIN to super admin" +fi + +# 7. Summary ----------------------------------------------------------------- +echo "" +log "Multisite ready. Endpoints (host):" +log " Network admin: http://localhost:8080/wp-admin/network/" +log " Main site: http://localhost:8080/" +if $CREATE_SECOND_SITE; then + log " Second site: http://localhost:8080/$SECOND_SITE_SLUG/" +fi +log " All sites: docker compose exec wordpress wp site list --allow-root"