Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/indianopinion/ascii-slugs/archive/refs/heads/main.zipWordPress percent-encodes slugs made from titles in Devanagari, Tamil, Urdu
and other non-Latin scripts, then silently cuts them at 200 bytes, which is
about 22 Devanagari letters and usually mid-word. ascii-slugs turns those
titles into short, readable ASCII slugs with built-in tables (no intl
needed), falls back to a short dated hash when it cannot, and ships a
dry-run-first repair command for slugs that are already broken.
Measured: चंद्रयान मिशन की सफलता पर देश में जश्न (38 code points) needs
286 bytes once percent-encoded, so WordPress stores a slug cut at 200 bytes;
ascii-slugs gives chandrayan-mishan-ki-saphalata-par-desh-men-jashn
(49 bytes).
It has two parts:
- A PHP library (
IndianOpinion\AsciiSlugs) with no dependencies and no WordPress code. Use it in any PHP 8.1+ project. - A thin WordPress plugin on top of it that applies the library to new post and term slugs, plus WP-CLI commands and a Site Health section.
This project came out of the work on indianopinion.org and is written from scratch; it is independent of and not affiliated with any third-party plugin it integrates with.
Contents
Install
Library, with Composer
composer require indianopinion/ascii-slugs
Library, without Composer
Download a release, then:
require '/path/to/ascii-slugs/bootstrap.php';
bootstrap.php registers an autoloader. Nothing else is needed.
WordPress plugin
- Copy the
ascii-slugsfolder towp-content/plugins/. - Activate ASCII Slugs under Plugins. No settings screen; see Configuration.
Requires WordPress 6.4 or later and PHP 8.1 or later. Composer is not needed at runtime. Activating it changes no stored slug.
WordPress, as a must-use plugin
- Copy the
ascii-slugsfolder towp-content/mu-plugins/ascii-slugs/. - Copy
examples/mu-plugin-loader.phptowp-content/mu-plugins/ascii-slugs.php. It is four lines:
<?php
if (!defined('ABSPATH')) {
exit;
}
require_once __DIR__ . '/ascii-slugs/ascii-slugs.php';
On multisite it works per site, with no network options.
Quick start
Library
use IndianOpinion\AsciiSlugs\Slugger;
$slugger = new Slugger();
$result = $slugger->slug('ISRO का चंद्रयान-3 मिशन');
$result->slug; // "isro-ka-chandrayan-3-mishan"
$result->method; // "transliterated"
$result->coverage; // 1.0
$result->scripts; // ["latin", "devanagari"]
$slugger->slug('தமிழ்')->slug; // "tamizh"
$slugger->slug('لاہور میں بارش')->slug; // "lahor-mein-barsh"
$slugger->slug('Москва')->slug; // "moskva"
// A script with no built-in table, on a host without intl, on 12 November 2026:
$slugger->slug('ข่าวด่วนวันนี้')->slug; // "post-20261112-459eae08"
Uniqueness is your job outside WordPress:
$slug = $slugger->unique($result->slug, fn (string $s): bool => $repository->slugExists($s));
// "isro-ka-chandrayan-3-mishan", or "-2", "-3" ... up to 50 tries,
// then SlugCollisionException.
More in examples/: basic.php, options-and-unique.php,
custom-table.php. Each runs with php examples/<file>.
WordPress
Activate the plugin and publish a post titled ISRO का चंद्रयान-3 मिशन. Its
slug is isro-ka-chandrayan-3-mishan. Category खेल gets the slug khel.
English titles are untouched: when core would not percent-encode anything,
the plugin does nothing.
Configuration
Library options (SlugOptions)
use IndianOpinion\AsciiSlugs\SlugOptions;
$options = new SlugOptions(maxLength: 120, fallbackPrefix: 'story', intlMode: 'never');
$slugger = new Slugger($options);
| Option | Type | Default | Allowed | What it does |
|---|---|---|---|---|
maxLength |
int | 80 |
20 to 190 | Longest slug. Cut at a hyphen when possible. 190 leaves room for core's -2 |
minCutPosition |
int | 20 |
1 to maxLength |
A word-boundary cut before this position would leave a stub, so the cut is hard instead |
minAlnum |
int | 3 |
1 to 20 | Fewer [a-z0-9] characters than this gives the hash |
minCoverage |
float | 0.5 |
0.0 to 1.0 | Share of letters that must be transliterated, or the hash is used |
fallbackPrefix |
string | post |
^[a-z][a-z0-9]{0,19}$ |
First part of the hash fallback |
hashLength |
int | 8 |
6 to 16 | Hex characters of SHA-256 in the fallback |
includeDate |
bool | true |
Put yyyymmdd (UTC) in the fallback |
|
intlMode |
string | auto |
auto, never, prefer |
auto: ICU only for scripts with no table. never: no ICU. prefer: ICU for every non-ASCII run (output then depends on the ICU version) |
dropFinalInherentVowel |
bool | true |
भारत gives bharat, or bharata when false. Devanagari, Bengali, Gurmukhi and Gujarati only |
Invalid values raise InvalidOptionException. SlugOptions::fromArray()
and toArray() convert to and from arrays, and every option has a
with*() method that returns a copy.
Plugin filters and actions
| Name | Type | Arguments | Default |
|---|---|---|---|
ascii_slugs_enabled |
filter | bool |
true, or false when the constant ASCII_SLUGS_DISABLED is true |
ascii_slugs_options |
filter | array of the options above |
the defaults above |
ascii_slugs_should_handle |
filter | bool, raw title, context |
true |
ascii_slugs_post_types |
filter | string[] |
every registered type except revision, nav_menu_item, customize_changeset, oembed_cache, user_request, wp_block |
ascii_slugs_slug |
filter | slug, raw title, SlugResult |
the library slug |
ascii_slugs_log |
filter | bool |
true when WP_DEBUG_LOG is on |
ascii_slugs_site_health_scan_limit |
filter | int |
100000: above this many published posts, Site Health does not count encoded slugs |
ascii_slugs_generated |
action | slug, raw title, SlugResult |
|
ascii_slugs_error |
action | Throwable, raw title |
|
ascii_slugs_repaired |
action | post ID, old slug, new slug |
Kill switch: define('ASCII_SLUGS_DISABLED', true); in wp-config.php,
or return false from ascii_slugs_enabled. Every hook then returns its
input unchanged.
A filter that returns the wrong type is ignored (and logged when logging is
on). A slug from ascii_slugs_slug is cleaned again to [a-z0-9-] and
maxLength; if nothing is left, the hash is used. Invalid options fall back
to the defaults. See examples/wordpress-filters.php.
The plugin stores nothing: no options, tables, post meta, transients or
files. uninstall.php removes nothing because there is nothing to remove,
and it never rewrites slugs back.
WP-CLI
wp ascii-slugs preview "ISRO का चंद्रयान-3 मिशन" # slug, method, coverage, scripts
wp ascii-slugs status # version, kill switch, intl, options, post types
wp ascii-slugs audit --format=count # percent-encoded slugs still stored
wp ascii-slugs audit --post_type=post --limit=20 # list them, read-only
wp ascii-slugs repair # dry run: shows old -> new, writes nothing
wp ascii-slugs repair --ids=12,15 --yes # writes
audit and repair take --post_type=<list> (default: the covered post
types), --status=<list> (default publish) and --limit=<n> (default
100). audit --format=count ignores --limit and counts every match with
one query. repair also takes --ids=<list>, --batch=<n> (default 50, the
runtime object cache is flushed between batches) and --yes.
What repair --yes does, so you can decide whether to run it:
- It builds each new slug from the stored post title, not from the cut slug.
- It saves through
wp_update_post(). Core records the old slug and its old-slug redirect sends the old URL to the new one with a 301. - The post's modified date moves to now. That is core behaviour.
- It skips pages and other hierarchical post types, because core does not redirect their old slugs. Use a redirect plugin for those.
- It changes only the slug. WP-CLI usually runs with no user, so core's
kses filters are on and would strip iframes and embeds from the content
that
wp_update_post()saves again; repair turns them off for that one save and back on after it. - It does not purge edge caches or rebuild sitemaps. Hook
ascii_slugs_repairedor purge as usual. - It is idempotent: a second run finds nothing. If one post fails, the rest are still repaired and the command exits with code 1.
The number to watch is wp ascii-slugs audit --format=count. Once the
plugin is active it should stop growing. Site Health (Tools, Site Health,
Info, "ASCII Slugs") shows the same count and whether intl is available.
The count cannot use an index (post_name REGEXP), so it reads every
published row of the covered post types. Measured with MySQL 8.0 on a
2 vCPU VM: 0.25 s for 100,000 posts in memory, 36 s for 1,000,000 posts
that did not fit the buffer pool. Site Health therefore first counts rows
on the post type index (0.15 s, stopping at the limit) and skips the
encoded count above 100,000 published posts, pointing to the WP-CLI
command instead. Change the limit with ascii_slugs_site_health_scan_limit.
audit and repair read the table the same way, in batches, so on a
very large site with few encoded slugs each batch can take seconds.
How it works
- Prepare. Check UTF-8, decode HTML entities (up to three passes, for
feed titles such as
&#8217;), strip tags, drop zero-width joiners, direction marks, soft hyphens and Arabic tatweel, and remove apostrophes inside words (Modi’sgivesmodis). - Split into runs of one script each. Digits, spaces and punctuation sit between runs.
- Transliterate each run with its own table. Built-in tables cover
Devanagari, Bengali-Assamese, Gurmukhi, Gujarati, Odia, Tamil, Telugu,
Kannada, Malayalam, Arabic script with Urdu rules, Cyrillic, Greek and
Latin with diacritics. Each table loads on first use. Indic tables
handle the inherent vowel, matras, virama conjuncts, anusvara, nukta
letters and native digits. Runs in other scripts go to ICU when
intlis installed, or are dropped. - Clean and cut. Lower case,
[a-z0-9]joined by single hyphens, cut at a word boundary undermaxLength. - Readability floor. The slug is used only if it has at least three
letters or digits, at least one letter, and at least half of the title's
letters were transliterated. Otherwise the result is
{prefix}-{yyyymmdd}-{hash}, 22 characters with the defaults.
In WordPress, the plugin hooks sanitize_title at priority 9 (before core)
and acts only in the save context, only when core would percent-encode
something. Stored slugs are ASCII (%e0%a4...), so they take the fast path
and never move. A second hook on wp_unique_post_slug catches slugs that
arrive already percent-encoded, and finishes with core's own uniqueness
check. Uniqueness always comes from core.
Screens that let you edit a slug show the stored one URL-decoded and send
it back decoded on every save. For posts, core spots that round trip itself
(the old-save context) and keeps the stored slug. For terms it does not,
so a third hook on pre_term_slug (priority 9) hands back the stored
encoded slug when the decoded value matches a term in the same taxonomy.
Editing a category's description therefore never moves its URL; typing a
new slug still gives an ASCII one.
The details, the full Cyrillic and Greek tables, and every design decision are in docs/ARCHITECTURE.md.
Limitations
- Not scholarly transliteration. Long and short vowels fold together and the output does not round-trip.
- No Hindi medial schwa deletion:
कमलाgiveskamala,पहलेgivespahale. - Urdu leaves short vowels unwritten, so most words come out as a consonant
skeleton with long vowels (
پاکستانgivespakstan). Twenty common words are spelled out from an exception list. - No built-in table for Sinhala, Meetei Mayek, Ol Chiki, Tibetan, Thai,
Chinese, Japanese, Korean or Hebrew in 0.1.0. They need
intl, or they get the hash. You can add a table withTransliterator::withTable(). - ICU output can differ between ICU versions. Slugs are computed once and stored, so this only matters if you rebuild slugs on another host.
- Pages and other hierarchical types are not repaired.
- A new post that arrives with an already percent-encoded slug and no title is rebuilt from the decoded slug, which may already have been cut.
- Speed misses the PRD budget of 0.5 ms for a 200-code-point Devanagari title including the first table load. Measured on a shared 2 vCPU VM, PHP 8.3, OPcache on (median of 50 requests): about 1.2 ms for the first slug in a request (0.45 ms of it loading the table), about 0.5 to 0.6 ms once the table is loaded, about 4 ms with OPcache off. A plain ASCII title costs one byte-class test. See ARCHITECTURE section 5.
Future
repair --keep-modified, which would need a direct write and a hand-made old-slug record. Not in 0.1.0 on purpose.- More built-in tables (Sinhala, Meetei Mayek, Ol Chiki, Tibetan).
FAQ
Will activating it change my existing URLs?
No. Activating, deactivating and uninstalling change no stored slug. Only
wp ascii-slugs repair --yes changes stored slugs, and it keeps the old URLs
working through core's redirect.
My English titles get different slugs now? No. If core would not percent-encode anything (curly quotes, en dashes, accented Latin), the plugin returns the title untouched and core does what it always did.
Do I need the intl extension?
No. The scripts listed above work the same with and without it. intl only
adds coverage for scripts with no built-in table.
An editor typed a Hindi slug by hand and it became ASCII. Can I keep typed
slugs?
Yes: return false from ascii_slugs_should_handle. The filter gets the raw
text and the context; sanitize_title cannot tell a typed slug from a
title, so your callback decides. See examples/wordpress-filters.php.
Are category and tag slugs covered?
Yes. Every save call of sanitize_title is handled, including term names.
Use ascii_slugs_should_handle to opt out. A term that already has a
percent-encoded slug keeps it when you edit the term, because the edit
screen sends the decoded slug back unchanged (see How it works).
Two posts got the same hashed slug.
They had the same title on the same day. Core adds -2. The hash is not a
security device and must not be used for access checks.
Where are the logs?
With WP_DEBUG_LOG on (or ascii_slugs_log returning true), one line per
non-ASCII slug, for example
ascii-slugs: method=hashed coverage=0.00 slug=post-20261112-3f9a1c0b. Lines
never include the title.
Contributing and security
See CONTRIBUTING.md and SECURITY.md. Questions: hello@indianopinion.org.
Licence
MIT. See LICENSE.