WP Manifestindependent plugin directory
manifest / seo / entity-linker

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

0stars
0forks

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.zip

Human-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:

  1. Build a dictionary from selected posts, pages, custom post types, taxonomy terms, and aliases.
  2. Scan selected editorial content for complete-phrase mentions.
  3. Group repeated mentions by source and destination.
  4. Hold every suggestion for review.
  5. Approve, reject, ignore, retarget, or approve a phrase globally.
  6. 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&T and 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 mbstring extension (required)
  • Advanced Custom Fields is optional

Installation

  1. Download the release ZIP.
  2. In WordPress, go to Plugins → Add New → Upload Plugin.
  3. Upload the ZIP and activate Hormuz Entity Linker.
  4. Open Entity Linker → Settings.
  5. Select source content types and valid link destinations.
  6. 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’sMcDonald's، Rolls–RoyceRolls-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 decision
  • approved — eligible for frontend rendering
  • conflict — the same phrase resolves to multiple destinations
  • rejected — explicitly rejected for this source
  • ignored — intentionally hidden from the normal review queue
  • obsolete — no longer detected in the latest completed scan
  • target_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_allowed
  • hel_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_options by 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.