فخ الـ 200 بايت: كيف تتسبب تحديثات ووردبريس في تعطّل روابط الصفحات العربية
جعفر أبازيد آخر تحديث
بعد تحديثٍ دوري لووردبريس، قد تبدأ صفحاتك العربية ذات العناوين الطويلة بإرجاع أخطاء 404 دون أيّ تحذير. والسبب أن ووردبريس يُخزّن الـ slug مُرمَّزًا بالشكل percent-encoded في أعمدة من نوع VARCHAR(200)، والحرف العربي الواحد يكلّف نحو 6 بايت بعد الترميز، فلا يتّسع العمود إلا لنحو 33 حرفًا.
قد تقوم بزيادة سعة هذه الأعمدة إلى VARCHAR(1024) مثلاً، لكنّ هذا لا يدوم طويلاً، فمع كل تحديثٍ أساسي لووربريس، يُعيد dbDelta المخطّط إلى VARCHAR(200) ويقتطع كل ما زاد، بصمتٍ ودون رجعة. وهي ليست عِلّة واحدة، بل ثلاثة مواضع في نواة ووردبريس يُثبّت كلٌّ منها الرقم 200.
الحلّ كان بكتابة إضافةٍ من نوع must-use تمنع الاقتطاع من جذره بدل إصلاحه بعد وقوعه: تُعيد كتابة المخطّط الذي يقارن به dbDelta فلا يرى أيّ فرق، وتستبدل مولّد الـ slug بنسخةٍ تفحص نفسها تلقائيًا مقابل النواة عند كل تحديث وتتراجع بأمان إن غيّرت النواة دالّتها يومًا، مع آلية للتحقق من عرض الأعمدة بعد كل تحديث وتُنبّهك فور انكماش أيٍّ منها.
ضَع الملف التالي في wp-content/mu-plugins/، ووسّع العمودين مرّة واحدة إلى VARCHAR(1024)، ونم قرير العين:
<?php
/**
* Plugin Name: Arabic Slug Schema Guard
* Plugin URI: https://github.com/mantekio/wp-arabic-slug-schema-guard
* Description: Keeps wp_posts.post_name and wp_terms.slug at VARCHAR(1024) across
* WordPress core database upgrades, so long URL-encoded Arabic slugs
* are never truncated. Prevention-first: dbDelta never truncates,
* because the canonical schema it diffs against already says 1024.
* The slug fork self-tests against core on each update and can fail safe.
* Version: 1.1.0
* Author: ManTek Technologies
* License: GPL-2.0-or-later
*/
defined( 'ABSPATH' ) || exit;
const ASG_COLUMN_LEN = 1024; // physical column width (bytes)
const ASG_SLUG_BYTES = 1000; // max generated slug length, under the column width
const ASG_SCHEMA_SIG = 'post_name:1024;slug:1024';
// define( 'ASG_ALERT_EMAIL', 'ops@example.com' ); // optional, set in wp-config.php
// define( 'ASG_L2_FAILSAFE', true ); // optional: on Layer-2 drift, fall back to core (slugs cap at 200) until you re-sync
/* LAYER 1 — PREVENTION
* Rewrite the canonical CREATE TABLE dbDelta diffs against, so "desired" already
* equals the live 1024-wide column and no destructive CHANGE COLUMN is emitted. */
add_filter( 'dbdelta_create_queries', function ( $queries ) {
global $wpdb;
foreach ( array( $wpdb->posts => 'post_name', $wpdb->terms => 'slug' ) as $table => $column ) {
if ( isset( $queries[ $table ] ) ) {
$queries[ $table ] = preg_replace(
'/(\b' . $column . '\s+varchar\()\s*200\s*(\))/i',
'${1}' . ASG_COLUMN_LEN . '${2}',
$queries[ $table ]
);
}
}
return $queries;
} );
/* LAYER 2 — GENERATION
* Core caps new slugs at 200 bytes via utf8_uri_encode($title, 200). Swap in a
* copy of sanitize_title_with_dashes() whose only change is the byte budget.
* Installed conditionally: if the drift guard (below) flagged THIS core version
* and ASG_L2_FAILSAFE is on, stay on core's default (caps at 200 — degraded but
* safe and known) rather than run a fork we know has drifted. */
if ( get_option( 'asg_l2_status' ) === 'drift:' . $GLOBALS['wp_version']
&& defined( 'ASG_L2_FAILSAFE' ) && ASG_L2_FAILSAFE ) {
// Known-drifted on this core version → leave core's generator in place.
} else {
remove_filter( 'sanitize_title', 'sanitize_title_with_dashes' );
add_filter( 'sanitize_title', 'asg_sanitize_title_with_dashes', 10, 3 );
}
function asg_sanitize_title_with_dashes( $title, $raw_title = '', $context = 'display' ) {
$title = strip_tags( $title );
// Preserve already-encoded octets through the cleanup below.
$title = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '---$1---', $title );
$title = str_replace( '%', '', $title );
$title = preg_replace( '|---([a-fA-F0-9][a-fA-F0-9])---|', '%$1', $title );
if ( seems_utf8( $title ) ) {
if ( function_exists( 'mb_strtolower' ) ) {
$title = mb_strtolower( $title, 'UTF-8' );
}
$title = utf8_uri_encode( $title, ASG_SLUG_BYTES ); // core hard-codes 200 here
}
$title = strtolower( $title );
if ( 'save' === $context ) {
// كل ما في هذه الكتلة منقول حرفياً عن الـ core. الفرق الوحيد المسموح به بين
// نسختنا والـ core هو حدّ البايتات أعلاه. أيّ سطر ناقص هنا يعني انحرافاً.
$title = str_replace( array( '%c2%a0', '%e2%80%93', '%e2%80%94' ), '-', $title );
$title = str_replace( array( ' ', ' ', '–', '–', '—', '—' ), '-', $title );
$title = str_replace( '/', '-', $title );
// تُحذف هذه المحارف كلياً. انتبه إلى %c2%ab و %c2%bb: علامتا التنصيص «...»
// العربيتان. وإلى %e2%80%8e و %e2%80%8f: علامتا اتجاه النص (LRM و RTL).
$title = str_replace(
array(
'%c2%ad',
'%c2%a1', '%c2%bf',
'%c2%ab', '%c2%bb', '%e2%80%b9', '%e2%80%ba', // علامات التنصيص الزاويّة
'%e2%80%98', '%e2%80%99', '%e2%80%9c', '%e2%80%9d', // التنصيص المنحني
'%e2%80%9a', '%e2%80%9b', '%e2%80%9e', '%e2%80%9f',
'%e2%80%a2',
'%c2%a9', '%c2%ae', '%c2%b0', '%e2%80%a6', '%e2%84%a2',
'%c2%b4', '%cb%8a', '%cc%81', '%cd%81',
'%cc%80', '%cc%84', '%cc%8c',
'%e2%80%8b', '%e2%80%8c', '%e2%80%8d', // محارف بلا عرض
'%e2%80%8e', '%e2%80%8f', // LRM و RTL mark
'%e2%80%aa', '%e2%80%ab', '%e2%80%ac',
'%e2%80%ad', '%e2%80%ae',
'%ef%bb%bf', // byte order mark
'%ef%bf%bc',
),
'',
$title
);
$title = str_replace( '%c3%97', 'x', $title ); // علامة الضرب × تصبح x
}
$title = preg_replace( '/&.+?;/', '', $title );
$title = str_replace( '.', '-', $title );
$title = preg_replace( '/[^%a-z0-9 _-]/', '', $title );
$title = preg_replace( '/\s+/', '-', $title );
$title = preg_replace( '|-+|', '-', $title );
return trim( $title, '-' );
}
/* LAYER 2 DRIFT GUARD — self-test the fork against core's live implementation.
* remove_filter() only unhooks sanitize_title_with_dashes(); the function stays
* callable, so we use core's CURRENT code as a live oracle and assert our fork
* still agrees on SHORT inputs (where neither byte cap engages). Any divergence
* == core altered the cleanup logic and our copy has drifted. Once per version. */
add_action( 'admin_init', 'asg_guard_layer2_drift' );
function asg_guard_layer2_drift() {
$ok = 'ok:' . $GLOBALS['wp_version'];
$drifted = 'drift:' . $GLOBALS['wp_version'];
if ( in_array( get_option( 'asg_l2_status' ), array( $ok, $drifted ), true ) ) {
return;
}
if ( ! function_exists( 'sanitize_title_with_dashes' ) ) {
return; // core refactored it away — fork still runs as a standalone copy.
}
$fixtures = array(
'الذكاء الاصطناعي',
'عاجل: تطورات «مهمة» اليوم',
'Mixed عربي + English — dash',
'foo/bar — baz',
'UPPER multiple spaces',
'%d8%a7 pre-encoded octet',
);
$drift = array();
foreach ( $fixtures as $f ) {
$core = sanitize_title_with_dashes( $f, $f, 'save' );
$ours = asg_sanitize_title_with_dashes( $f, $f, 'save' );
if ( $core !== $ours ) {
$drift[] = "in=[{$f}] core=[{$core}] ours=[{$ours}]";
}
}
if ( $drift ) {
$msg = "[Arabic Slug Schema Guard] Layer-2 DRIFT on WP {$GLOBALS['wp_version']}: core "
. "sanitize_title_with_dashes() no longer matches our fork — re-sync the copy.\n"
. implode( "\n", $drift );
error_log( $msg );
if ( defined( 'ASG_ALERT_EMAIL' ) && function_exists( 'wp_mail' ) ) {
wp_mail( ASG_ALERT_EMAIL, 'WP slug Layer-2 drift: ' . wp_parse_url( home_url(), PHP_URL_HOST ), $msg );
}
update_option( 'asg_l2_status', $drifted, true );
return;
}
update_option( 'asg_l2_status', $ok, true );
}
/* LAYER 3 — DE-DUPLICATION (optional)
* _truncate_post_slug() also caps at 200, but only on a slug COLLISION needing a
* "-2" suffix — rare for unique headlines, and not filterable. Most sites skip it.
* add_filter( 'pre_wp_unique_post_slug', 'asg_unique_post_slug', 10, 6 );
*/
/* TRIPWIRE — verify + alert after every core update. Restores the COLUMN, never
* bytes already truncated. Treat any revert as an incident — restore from backup. */
add_action( 'upgrader_process_complete', function ( $upgrader, $extra ) {
if ( isset( $extra['type'] ) && 'core' === $extra['type'] ) {
delete_option( 'asg_schema_sig' );
}
}, 10, 2 );
add_action( 'admin_init', function () {
if ( get_option( 'asg_schema_sig' ) === ASG_SCHEMA_SIG ) {
return;
}
$reverted = asg_reverted_columns();
if ( $reverted ) {
$msg = '[Arabic Slug Schema Guard] Column(s) reverted: ' . implode( ', ', $reverted )
. '. Long slugs were truncated — restore from backup and check 404 logs.';
error_log( $msg );
if ( defined( 'ASG_ALERT_EMAIL' ) ) {
wp_mail( ASG_ALERT_EMAIL, 'WP slug schema reverted: ' . wp_parse_url( home_url(), PHP_URL_HOST ), $msg );
}
return;
}
update_option( 'asg_schema_sig', ASG_SCHEMA_SIG, false );
} );
function asg_reverted_columns() {
global $wpdb;
$reverted = array();
foreach ( array( array( $wpdb->posts, 'post_name' ), array( $wpdb->terms, 'slug' ) ) as list( $table, $column ) ) {
$len = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s",
DB_NAME, $table, $column
) );
if ( $len && $len < ASG_COLUMN_LEN ) {
$reverted[] = "{$table}.{$column} (now {$len})";
}
}
return $reverted;
}
/* WP-CLI — `wp asg verify` for cron-based monitoring */
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'asg verify', function () {
foreach ( asg_reverted_columns() ?: array( 'ok' ) as $row ) {
WP_CLI::log( 'ok' === $row ? 'Schema OK — both columns at VARCHAR(1024).' : "REVERTED: {$row}" );
}
} );
}
📦 الإضافة مفتوحة المصدر (GPL):
composer require mantekio/wp-arabic-slug-schema-guard، والمستودع على GitHub.
تذييل: حارس الانحراف أمسك بنا نحن
خلاصة ما سبق أنّ أيّ نسخة من دالة في الـ core تنحرف عنها بصمت، ولذلك فإنّ الحارس أهمّ من النسخة نفسها. وقد ثبت ذلك بأقسى طريقة ممكنة.
الإصدارات 1.0.0 حتى 1.2.0 من الإضافة المنشورة كانت تحمل نسخةً تنقصها كتلة «احذف هذه المحارف كلياً» بأكملها، ومعها تحويل علامة الضرب × إلى x. والنتيجة تقع تحديداً على القارئ الذي كُتب له هذا المقال: علامتا التنصيص العربيتان («» ) وعلامتا اتجاه النص (LRM و RTL) كانت تنجو إلى الـ slug على هيئة %c2%ab و %c2%bb و %e2%80%8f. والمرشِّح في آخر الدالة عاجز عن إزالتها، لأنّ %c2%ab مبنيّ بالكامل من محارف مسموح بها.
حارس الانحراف المكتوب أعلاه كان سيمسك بها من أوّل admin_init، فعيّنته الثانية هي 'عاجل: تطورات «مهمة» اليوم' نفسها. لكنّه لم يعمل قطّ، لأنّه معلّق على admin_init، ولم يفتح أحدٌ لوحة تحكّم ووردبريس على تثبيتٍ يشغّل تلك النسخة. حارسٌ على خطّافٍ لا يُستدعى ليس حارساً، بل تعليق.
كلاهما مُصلَح في v1.3.0: أُعيدت مزامنة النسخة مع الـ core (والكتلة أعلاه صارت كاملة، ويتحقّق منها اختبارٌ مرجعيّ مع كلّ بناء)، وصار wp asg verify يشغّل فاحص الانحراف بنفسه بدل انتظار زيارةٍ للوحة التحكّم. إن كنت قد نسخت هذا الكود قبل تموز/يوليو 2026، فخُذ كتلة str_replace أعلاه.
هذه نسخة مختصرة. للتحليل التقني الكامل، رياضيات البايتات وراء الاقتطاع، المواضع الثلاثة في النواة سطرًا بسطر، آليّة الفحص الذاتي للنسخة، ولماذا يبقى VARCHAR(1024) آمنًا (حدّ فهرس الـ 191 محرفًا)، وخطّة الترحيل لجداولٍ بملايين الصفوف. راجِع النسخة الإنجليزية الكاملة:
→ The 200-byte trap: why WordPress core updates break Arabic URLs