Angka yang membuat saya berhenti bukan angka error, tapi angka enam.
Enam halaman berstatus published, keenamnya terdaftar rapi di page-sitemap.xml, keenamnya sudah lama disodorkan ke mesin pencari, dan tidak satu pun benar-benar bisa dibuka. Saya menemukannya bukan dari laporan siapa pun. Saya kebetulan sedang menyisir sitemap sebuah instalasi WordPress lama untuk keperluan lain, lalu iseng menjalankan setiap URL di dalamnya dan mencetak status codenya:
xmllint --xpath '//*[local-name()="loc"]/text()' page-sitemap.xml \
| while read -r url; do
printf '%s %s\n' "$(curl -sL -o /dev/null -w '%{http_code}' "$url")" "$url"
doneEnam baris di bawah satu prefix yang sama pulang seperti ini:
404 https://example.com/program/jadi-bintang-tamu/
404 https://example.com/program/daftar-jadi-tamu/
404 https://example.com/program/jadi-sponsor/
404 https://example.com/program/zona-karier/
404 https://example.com/program/arsip-video-obrolan-pagi/
200 https://example.com/program/arsip-video-seri-malam/
Lima 404 itu buruk, tapi baris terakhir yang membuat saya duduk lebih tegak. Saya buka URL-nya di browser dan yang muncul memang halaman yang tampak sehat, hanya saja isinya arsip yang sama sekali lain, bukan halaman anak yang seharusnya tinggal di situ. Status 200 di atas konten yang salah adalah jenis kerusakan yang tidak pernah dilaporkan siapa-siapa, karena dari luar tidak ada yang kelihatan patah.
Halamannya ada, resolusinya yang tidak
Dugaan pertama saya yang paling murah: seseorang membuang halaman-halaman itu ke trash, atau memindahkan parent-nya. Di admin semuanya utuh. Enam halaman published, parent-nya benar, dan kolom permalink di editor menampilkan persis URL yang barusan membalas 404 kepada saya.
Dari sisi PHP jawabannya sama:
$page = get_page_by_path('program/jadi-bintang-tamu', OBJECT, 'page');
var_dump($page instanceof WP_Post, $page->post_status);
// bool(true)
// string(7) "publish"Datanya ada, statusnya publish, path-nya cocok. Jadi yang rusak bukan kontennya, melainkan cara URL itu diterjemahkan menjadi query. Dan kalau yang rusak adalah penerjemahan URL, ada satu tempat yang menyimpan jawabannya secara harfiah.
Tabel rewrite menyebut siapa pemilik prefix itu
WordPress menaruh seluruh peta URL-nya di satu option. Saya cetak semua rule yang dimulai dengan prefix tersebut:
foreach ((array) get_option('rewrite_rules') as $regex => $query) {
if (strpos($regex, 'program') === 0) {
echo $regex . ' => ' . $query . "\n";
}
}Baris pertamanya sudah cukup menjelaskan semuanya:
program/([^/]+)/?$ => index.php?seri_acara=$matches[1]
Prefix program bukan milik halaman itu lagi. Di berkas pendaftaran post type ada satu custom post type yang didaftarkan begini:
register_post_type('acara', [
// ...
'rewrite' => ['slug' => 'program'],
]);Nilai slug itu sama persis dengan slug halaman induk yang sudah lebih dulu ada di situs. Sejak registrasi tersebut masuk, setiap URL satu segmen di bawah /program/ berhenti menjadi lookup halaman dan berubah menjadi lookup term taksonomi. Situs ini hanya punya dua term yang benar-benar ada, jadi semua halaman anak lain di bawah prefix yang sama resolve ke term yang tidak pernah dibuat, dan WordPress mengembalikan 404 dengan sangat percaya diri.
Tidak ada error PHP, tidak ada warning di log, tidak ada notice di admin. Tabrakan prefix seperti ini tidak melempar apa-apa. Dia cuma diam-diam memindahkan kepemilikan satu cabang URL ke tangan yang lain.
Baris 200 itu, dan alat ukur saya sendiri
Tinggal satu hal yang belum masuk akal: kenapa satu URL membalas 200 kalau term-nya juga tidak ada.
Jawabannya ada di flag yang saya ketik sendiri. Perintah audit tadi memakai -sL, dan -L berarti ikut ke mana pun server menyuruh pergi. Saya ulangi tanpa mengikuti redirect:
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
https://example.com/program/arsip-video-seri-malam/
# 301 https://example.com/program/seri-malam/Itu bukan halaman sehat, itu tebakan. Ketika sebuah permintaan berakhir 404, WordPress punya penanganan yang mencoba menebak slug terdekat lewat redirect_guess_404_permalink(), dan slug halaman anak yang satu ini kebetulan cukup mirip dengan salah satu dari dua term yang memang ada. Alih-alih mengaku tidak menemukan apa-apa, situsnya melempar pengunjung ke arsip lain, dan rantainya berakhir di 200. Untuk pengunjung dan untuk crawler, halaman itu hidup dan sehat. Isinya saja yang bukan yang dijanjikan sitemap.
Kebiasaan -L yang sama menagih ongkosnya sekali lagi beberapa jam kemudian. Setelah tambalan pertama saya pasang, satu URL lain terbaca 200 di pengukuran sebelum dan 200 juga di pengukuran sesudah, padahal di baliknya ada 301 lama yang sudah berdiri jauh sebelum saya menyentuh apa pun. Selama beberapa menit saya sempat mencurigai perubahan saya sendiri. Baseline yang mengikuti redirect bukan baseline, itu ringkasan hasil akhir. Baca baris statusnya, bukan halaman terakhir yang mendarat di layar.
Perbaikannya: fallback, bukan resolusi utama
Ada dua jalan yang saya coret duluan. Mengganti rewrite slug CPT-nya berarti memindahkan seluruh URL entri yang sudah terlanjur beredar, dan itu masalah yang lebih besar daripada yang sedang saya perbaiki. Menambah rewrite rule baru berarti menambah aturan ke tabel yang tadi sudah terbukti jadi sumber tabrakan, plus flush dan sebuah gerbang versi untuk memicunya.
Yang tersisa adalah menyentuh resolusi di titik ketika WordPress sudah selesai memutuskan, tepat sebelum query dieksekusi, lewat filter request. Kuncinya bukan filternya, melainkan urutan yang saya taruh di dalamnya:
add_filter('request', function ($qv) {
if (empty($qv['seri_acara']) || !is_string($qv['seri_acara'])) {
return $qv;
}
// Term yang benar-benar ada tetap menang. Filter ini tidak ikut campur.
if (get_term_by('slug', $qv['seri_acara'], 'seri_acara')) {
return $qv;
}
// Baru sisanya ditawarkan ke halaman anak dengan path yang sama.
$path = 'program/' . $qv['seri_acara'];
$page = get_page_by_path($path, OBJECT, 'page');
if ($page instanceof WP_Post && $page->post_status === 'publish') {
unset($qv['seri_acara']);
$qv['pagename'] = $path;
}
return $qv;
});Tiga hal kecil di sana yang saya anggap wajib. Query var bisa datang bukan sebagai string, jadi is_string() bukan hiasan. $path dibangun sebelum unset(), karena kalau urutannya kebalik nilainya sudah lenyap saat mau dipakai. Dan pengecekan post_status menjaga supaya draft tidak ikut terbuka lewat pintu ini.
Yang paling saya sukai dari bentuk ini justru apa yang tidak ada di dalamnya. Tidak ada rewrite rule baru, jadi tidak ada flush, tidak ada option versi, dan tidak ada langkah manual buka Settings lalu simpan permalink setelah deploy. Lookup-nya terjadi per permintaan, jadi halaman anak yang dibuat editor besok akan langsung jalan pada detik dia dipublish, tanpa satu pun baris kode ikut berubah.
Kenapa arah sebaliknya butuh alat yang berbeda
Saya pernah mengerjakan kasus kebalikannya di situs lain: membuang prefix supaya sebuah CPT tersaji langsung di root, /judul/ alih-alih /prefix/judul/. Di sana filter request justru alat yang salah, dan hasilnya jatuh diam-diam ke blog index. Jawaban yang benar di kasus itu adalah rewrite rule eksplisit.
Filternya sama, hasilnya berlawanan, dan pembedanya cuma satu pertanyaan: apakah ada yang lain yang sudah meresolve URL ini dengan benar. Di kasus root, tidak ada, sehingga filter itu naik pangkat jadi jalur resolusi utama, dan jalur utama tidak boleh diserahkan ke sesuatu yang jalan setelah keputusan diambil. Di kasus prefix yang tertelan ini, term yang sah tetap resolve seperti biasa dan saya hanya memungut sisanya. Fallback aman justru karena dia fallback.
Satu prefix, satu pemilik
Satu prefix URL cuma boleh punya satu pemilik. Sebelum memberi sebuah CPT atau taksonomi rewrite slug, cek dulu apakah sudah ada halaman yang tinggal di path itu. Satu panggilan get_page_by_path() sekali jalan sudah cukup, dan itu jauh lebih murah daripada menemukannya berbulan-bulan kemudian lewat sitemap. Tabrakannya tidak pernah melempar error, dia hanya memindahkan resolusi tanpa pamit.
Sisanya soal cara mengukur. Audit URL dengan membaca status code satu per satu, bukan dengan membuka halaman lalu merasa semuanya baik-baik saja, dan jangan biarkan alatnya mengikuti redirect saat kamu sedang mencari kebohongan. Enam halaman di situs itu rusak entah sejak kapan, dan satu-satunya alasan kerusakannya berhenti adalah karena saya membaca sitemap untuk urusan yang sama sekali berbeda.