Hormuz Entity Linker
Human-reviewed entity linking for WordPress. Detect mentions, review suggestions, and render only approved internal links.
by Hormuz Group · github.com/hormuzgroup/entity-linker · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/hormuzgroup/entity-linker/archive/refs/heads/main.zipHuman-reviewed entity linking for WordPress.
Hormuz Entity Linker scans selected WordPress content for mentions of known entities, queues suggestions for editorial review, and renders only approved internal links. It never rewrites stored post content.
Built and maintained by Hormuz Group.
Why it exists
Most automatic internal-linking plugins make the linking decision at scan time. Hormuz Entity Linker separates detection from publication:
- Build a dictionary from selected posts, pages, custom post types, taxonomy terms, and aliases.
- Scan selected editorial content for complete-phrase mentions.
- Group repeated mentions by source and destination.
- Hold every suggestion for review.
- Approve, reject, ignore, retarget, or approve a phrase globally.
- Render only approved links on the frontend.
This makes the plugin suitable for editorial sites, research platforms, knowledge bases, directories, and structured-content projects where incorrect automatic links are more costly than missing links.
Highlights
- Human review before a link can appear.
- Server-side rendering; no client-side link injection.
- Stored post and term content is never modified.
- Works with public posts, pages, custom post types, and public taxonomies.
- Manual entity aliases per destination post.
- Detects aliases from common ACF alias/name fields when ACF is present.
- Optional scanning of ACF WYSIWYG, textarea, repeater, group, and flexible-content fields.
- ACF textareas remain plain text; approved links render only in HTML-capable WYSIWYG output.
- First-class Persian and Arabic matching (see below).
- Longest-match-first phrase matching reduces nested entity collisions.
- Ambiguous phrases are surfaced as conflicts instead of silently choosing a destination.
- Existing links, headings, code, scripts, styles, buttons, forms, SVG, MathML, iframes, and registered shortcode regions are excluded.
- Entity-encoded text such as
AT&Tand texturized apostrophes (McDonald’s) match their plain-text names. - One approved destination is linked at most once per content scope.
- Batch scanning runs only from the authenticated admin workflow; normal visitor requests never execute a scan.
- Atomic scan lock prevents overlapping batches.
- Scales to large dictionaries: batched queries, compiled patterns, and bounded caches (see Performance).
Requirements
- WordPress 6.2+
- PHP 8.0+
- PHP
mbstringextension (required) - Advanced Custom Fields is optional
Installation
- Download the release ZIP.
- In WordPress, go to Plugins → Add New → Upload Plugin.
- Upload the ZIP and activate Hormuz Entity Linker.
- Open Entity Linker → Settings.
- Select source content types and valid link destinations.
- Open Entity Linker → Review Suggestions and start a scan.
Default configuration
A fresh installation starts conservatively:
- Sources: Posts and Pages
- Destination post types: Posts and Pages
- Destination taxonomies: Categories and Tags
- Scan excerpts: off
- Scan ACF: on when ACF is available
- Single-word taxonomy names: off
- Render in post content: on
- Render in excerpts: off
- Render in ACF WYSIWYG: on
- Batch size: 10 objects per authenticated admin request
- Maximum approved links per scope: 10
All of these can be changed in the settings screen.
Persian, Arabic and multilingual text
Matching is Unicode-aware and tolerant of the spelling variation that is normal in Persian and Arabic publishing. A dictionary phrase matches all of these surface forms:
| Variation | Example |
|---|---|
| Arabic vs Persian letters (ي/ی، ك/ک، ۀ/ه، أ/إ/ٱ/ا، ؤ/و) | بانك مركزي ↔ بانک مرکزی |
| Zero-width non-joiner used as a word separator | بانکمرکزی ↔ بانک مرکزی |
| ZWNJ inside a word, tatweel, diacritics | میخواهم ↔ میخواهم، مُحَمَّد ↔ محمد |
| Persian, Arabic-Indic and Latin digits | ۱۴۰۴ ↔ ١٤٠٤ ↔ 1404 |
| Typographic apostrophes and dashes | McDonald’s ↔ McDonald's، Rolls–Royce ↔ Rolls-Royce |
The linked text on the frontend is always the author's original spelling. Word boundaries use Unicode letter classes, so ایران does not match inside ایرانی. A ZWNJ-attached suffix (ایرانخودرو) is treated as part of the word by default; use hel_zwnj_is_word_boundary to change that.
The admin interface ships with a complete Persian (fa_IR) translation, supports right-to-left layouts, and displays mixed-direction phrases correctly.
Entity aliases
For every configured destination post type, the editor gets an Entity Link Aliases meta box. Add one accepted name per line, for example:
Example Corporation
Example Corp
EXCO
The title remains an alias automatically. Titles, taxonomy names and ACF aliases must meet the Minimum phrase length setting; manual aliases may be as short as two characters so that deliberate acronyms such as UN work (hel_manual_alias_min_length).
If ACF is installed, scalar fields whose names resemble alias, legal_name, official_name, short_label, ticker, symbol, or acronym can also contribute aliases.
Review states
pending— detected and awaiting a decisionapproved— eligible for frontend renderingconflict— the same phrase resolves to multiple destinationsrejected— explicitly rejected for this sourceignored— intentionally hidden from the normal review queueobsolete— no longer detected in the latest completed scantarget_missing— reserved for unavailable destination reconciliation
Global approval
Approve globally stores the phrase-to-destination rule. Future scans automatically approve the same unambiguous mapping, including other spelling variants of the phrase.
Grouping
Each destination is suggested once per source object. Repeated mentions and variants are grouped into that suggestion, which is attached to the scope (content, excerpt, ACF field, term description) where the destination first appears.
How rendering works
Approved links are generated at request time from review data. The plugin does not insert <a> tags into post_content, term descriptions, or ACF storage.
The renderer skips unsafe or semantically undesirable regions including:
- existing anchors
- headings
- code and preformatted blocks
- scripts and styles
- buttons and form controls
- SVG, MathML, iframes and templates
- registered shortcode regions
Only the first eligible occurrence of each approved destination is linked within a scope.
ACF behavior
ACF is entirely optional.
When available, Entity Linker can scan narrative WYSIWYG and textarea values, plus sufficiently long narrative text fields. Repeater, group, and flexible-content subfields are traversed recursively.
Textarea fields are scan-only because they are plain-text output surfaces. The plugin defensively unwraps any legacy hel-auto-link anchors it encounters in formatted textarea output.
Site-specific ACF rules can be implemented with filters described below.
Extension hooks
The public build deliberately keeps site-specific policy outside the core.
Change fresh-install defaults
add_filter( 'hel_default_settings', function ( $settings ) {
$settings['source_post_types'] = array( 'post', 'book' );
$settings['target_post_types'] = array( 'book', 'person' );
return $settings;
} );
Add aliases programmatically
add_filter( 'hel_post_aliases', function ( $aliases, $post ) {
if ( 'person' === $post->post_type ) {
$aliases[] = get_post_meta( $post->ID, '_short_name', true );
}
return $aliases;
}, 10, 2 );
Prioritize destination types
add_filter( 'hel_target_priority', function ( $priority, $target ) {
if ( 'person' === $target['object_type'] ) {
return 200;
}
return $priority;
}, 10, 2 );
Add stop phrases
add_filter( 'hel_stop_phrases', function ( $phrases ) {
$phrases[] = 'example';
return $phrases;
} );
Limit linking to selected <section> IDs
add_filter( 'hel_primary_content_section_ids', function ( $ids, $post_type ) {
if ( 'report' === $post_type ) {
return array( 'executive-summary', 'analysis' );
}
return $ids;
}, 10, 2 );
Supply a custom scan representation
add_filter( 'hel_source_content_for_scan', function ( $content, $post ) {
// Return the canonical narrative HTML that should be scanned.
return $content;
}, 10, 2 );
ACF policy
Available filters:
hel_acf_context_allowedhel_acf_excluded_context_patterns
Management capability
Use hel_manage_capability to replace the default manage_options capability.
Matching and performance filters
| Filter | Default | Purpose |
|---|---|---|
hel_zwnj_is_word_boundary |
false |
Treat ZWNJ as the end of a word, so ایران matches in ایرانخودرو. |
hel_manual_alias_min_length |
2 |
Minimum length for manual aliases. |
hel_acf_alias_field_names |
alias, aliases, legal_name, official_name, short_label, ticker, symbol, acronym | ACF field-name fragments that contribute aliases. |
hel_dictionary_query_batch_size |
1000 |
Destination rows loaded per query while building the dictionary. |
hel_dictionary_max_entries |
0 (unlimited) |
Hard cap on dictionary phrases. |
hel_dictionary_transient_max_bytes |
1 MB | Largest dictionary stored in a transient when no object cache is present. |
hel_render_cache_enabled |
true |
Disable render-rule caching for a source. |
hel_render_cache_ttl |
6 h (transients) / 1 day (object cache) | Lifetime of cached rule sets. |
hel_log_errors |
true |
Write scan failures to the PHP error log. |
Cache integration
The plugin fires:
hel_source_links_changed
after approved-link state invalidates a source. Cache/CDN integrations can listen to that action and purge the relevant URL.
Performance
- The destination dictionary is built with batched SQL queries (no
posts_per_page => -1), with aliases and ACF alias fields loaded in bulk. - Dictionary phrases are compiled once per request into chunked patterns and mapped back to entries through named groups.
- The dictionary uses a single cache slot: a persistent object cache when available, otherwise a transient only if it is smaller than 1 MB. Larger dictionaries are rebuilt per scan batch rather than bloating
wp_options. - Frontend rendering caches only the compact approved-rule set (phrase keys and URLs) for sources that actually have approved links. Rendered HTML is never cached, so dynamic markup cannot go stale, and pages without approved links write nothing to the database.
- Editing a destination's body does not invalidate anything; title, status or type changes invalidate the dictionary; slug, parent, status or permalink-structure changes invalidate rendered URLs, and orphaned transients are purged by a one-off cron event.
A persistent object cache (Redis or Memcached) is recommended for large sites, as with most WordPress plugins.
Data and privacy
Entity Linker does not send content or analytics to Hormuz Group or any third party.
It stores:
- scan/review state
- detected phrases
- a short context excerpt from selected published source content
- destination identifiers
- editorial review states
- global phrase rules
No visitor profile, IP address, cookie identifier, or browsing history is collected by the plugin.
Uninstall behavior
Deactivation stops active scan work but preserves review data.
Uninstall also preserves data by default. To explicitly remove Entity Linker tables and options during uninstall, define this before deleting the plugin:
define( 'HORMUZ_ENTITY_LINKER_REMOVE_DATA', true );
This removes the plugin tables, options, transients, cache-version meta and the Entity Link Aliases entered on destination posts. The opt-in behavior is intentional because review decisions and aliases can represent substantial editorial work.
Security model
- All management actions require a configurable WordPress capability (
manage_optionsby default). - AJAX mutations require a nonce.
- Only configured, published destinations can be selected.
- Full scans run in bounded administrator-triggered batches.
- A short-lived atomic option lock prevents concurrent scan batches.
- No arbitrary PHP, shell command, remote URL fetch, or SQL input is exposed.
Please report security issues through GitHub private vulnerability reporting rather than a public issue.
Development
composer install
composer lint
composer test:unit
composer phpcs
node --check assets/admin.js
Continuous integration runs the PHP checks on PHP 8.0 through 8.4 and verifies the admin JavaScript syntax.
See CONTRIBUTING.md for contribution guidelines.
Project status
Version 1.0.0 is the first stable public release. Public releases follow semantic versioning.
License
GPL-2.0-or-later. See LICENSE.