table_name = $wpdb->prefix . 'dawn_auto_links'; $this->dom_available = class_exists( 'DOMDocument' ); $this->fs_active = ( function_exists( 'dawnal_fs' ) && dawnal_fs() ); add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'admin_assets' ) ); add_action( 'admin_notices', array( $this, 'admin_notices' ) ); // Free deliberately links exact matches everywhere in eligible rendered // content, including headings. The main singular post/page title is also // eligible. Existing links and technical markup remain protected. add_filter( 'the_content', array( $this, 'auto_link_content' ), 99 ); add_filter( 'elementor/widget/render_content', array( $this, 'auto_link_content' ), 99 ); add_filter( 'elementor/frontend/the_content', array( $this, 'auto_link_content' ), 99 ); add_filter( 'the_title', array( $this, 'auto_link_title' ), 99, 2 ); } public static function activate_static() { $self = new self(); $self->activate(); } public static function deactivate_static() { wp_cache_delete( DAWNAL_CACHE_KEY_RULES ); delete_transient( 'dawnal_admin_notice' ); dawnal_log( 'Free plugin deactivated: cleared cache/transients.' ); } private function activate() { global $wpdb; delete_option( 'dawnal_last_activation_error' ); delete_option( 'dawnal_last_activation_error_data' ); delete_option( 'dawnal_last_activation_error_time' ); $wp_ok = version_compare( get_bloginfo( 'version' ), '5.0', '>=' ); $php_ok = version_compare( PHP_VERSION, '7.4', '>=' ); if ( ! $wp_ok || ! $php_ok ) { dawnal_set_activation_error( 'Requirements not met', array( 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'wp_ok' => $wp_ok, 'php_ok' => $php_ok, ) ); return; } if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$this->table_name} ( id mediumint(9) NOT NULL AUTO_INCREMENT, keyword varchar(255) NOT NULL, variations text DEFAULT NULL, url text NOT NULL, link_type varchar(20) NOT NULL DEFAULT 'internal', enabled tinyint(1) NOT NULL DEFAULT 1, clicks bigint(20) NOT NULL DEFAULT 0, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY unique_keyword (keyword), KEY keyword_idx (keyword), KEY type_idx (link_type), KEY clicks_idx (clicks) ) {$charset_collate};"; try { dbDelta( $sql ); } catch ( Throwable $e ) { dawnal_set_activation_error( 'dbDelta threw exception', array( 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), ) ); return; } $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $this->table_name ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching if ( ! $table_exists ) { dawnal_set_activation_error( 'Database table missing after dbDelta', array( 'table' => $this->table_name, 'db_error' => $wpdb->last_error, ) ); return; } // Do not create or overwrite premium behavior/settings options here. // Existing dawnal_* options are deliberately preserved for seamless upgrades. update_option( 'dawnal_activation_version', DAWNAL_VERSION, false ); update_option( 'dawnal_activated_successfully', 1, false ); wp_cache_delete( DAWNAL_CACHE_KEY_RULES ); dawnal_log( 'Free plugin activated OK', array( 'version' => DAWNAL_VERSION, 'table' => $this->table_name, 'dom' => $this->dom_available ? 'yes' : 'no', 'freemius' => $this->fs_active ? 'yes' : 'no', ) ); } public function admin_notices() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $err = get_option( 'dawnal_last_activation_error', '' ); if ( $err && current_user_can( 'activate_plugins' ) ) { $show = false; if ( $screen && isset( $screen->id ) ) { $show = ( 'plugins' === $screen->id || false !== strpos( $screen->id, 'rootlinkd' ) ); } if ( $show ) { echo '

rootlinkd: Activation issue: ' . esc_html( $err ) . '.

'; } } } public function add_menu() { add_menu_page( 'rootlinkd', 'rootlinkd', 'manage_options', 'rootlinkd', array( $this, 'main_page' ), 'dashicons-admin-links', 58 ); } public function admin_assets( $hook ) { if ( false === strpos( (string) $hook, 'rootlinkd' ) ) { return; } $css = ' .dal-wrap{max-width:1180px}.dal-tabs{display:flex;gap:0;border-bottom:1px solid #c3c4c7;margin:18px 0 22px}.dal-tabs a{padding:10px 14px;text-decoration:none;border:1px solid transparent;border-bottom:0;color:#1d2327}.dal-tabs a.is-active{background:#fff;border-color:#c3c4c7;font-weight:600;margin-bottom:-1px}.dal-card{background:#fff;border:1px solid #dcdcde;border-radius:10px;padding:20px;margin:0 0 20px}.dal-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}.dal-pill{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600}.dal-upsell{background:#111827;color:#fff;border-radius:10px;padding:18px;margin-top:22px}.dal-upsell h3{color:#fff}.dal-upsell a.button{background:#fff;border-color:#fff;color:#111827}.dal-url{font-size:11px;word-break:break-all} '; wp_register_style( 'dawnal-admin-free', false, array(), DAWNAL_VERSION ); wp_enqueue_style( 'dawnal-admin-free' ); wp_add_inline_style( 'dawnal-admin-free', $css ); } public function main_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $current_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'rules'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! in_array( $current_tab, array( 'rules', 'upgrade' ), true ) ) { $current_tab = 'rules'; } $tabs = array( 'rules' => 'Rules', 'upgrade' => '⭐ Upgrade', ); ?>

rootlinkd

upgrade_page_content(); } else { $this->rules_page_content(); } ?>
delete( $this->table_name, array( 'id' => $id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching wp_cache_delete( DAWNAL_CACHE_KEY_RULES ); $success_msg = 'Rule deleted.'; } elseif ( 'toggle' === $action ) { check_admin_referer( 'dawnal_toggle_' . $id ); $row = $wpdb->get_row( $wpdb->prepare( "SELECT enabled FROM {$this->table_name} WHERE id = %d", $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( $row ) { $new = ( 1 === (int) $row->enabled ) ? 0 : 1; $wpdb->update( $this->table_name, array( 'enabled' => $new ), array( 'id' => $id ), array( '%d' ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching wp_cache_delete( DAWNAL_CACHE_KEY_RULES ); $success_msg = $new ? 'Rule enabled.' : 'Rule disabled.'; } } elseif ( 'edit' === $action ) { check_admin_referer( 'dawnal_edit_' . $id ); $edit_rule = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE id = %d", $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( ! $edit_rule ) { $error_msg = 'Rule not found.'; } } } if ( isset( $_POST['dawnal_save_rule'] ) ) { check_admin_referer( 'dawnal_save_rule' ); $keyword = isset( $_POST['keyword'] ) ? sanitize_text_field( wp_unslash( $_POST['keyword'] ) ) : ''; $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; $edit_id = isset( $_POST['edit_id'] ) ? absint( $_POST['edit_id'] ) : 0; if ( '' === $keyword || '' === $url ) { $error_msg = 'Both keyword and URL are required.'; } elseif ( ! $this->is_internal_url( $url ) ) { $error_msg = 'rootlinkd Free creates internal links only. External and affiliate link handling is available in a paid plan.'; } else { $insert_data = array( 'keyword' => $keyword, 'variations' => '', 'url' => $url, 'link_type' => 'internal', 'enabled' => 1, ); // Preserve premium-only data and click history when an existing rule // is edited from Free. Free must never erase stored upgrade data. $update_data = array( 'keyword' => $keyword, 'url' => $url, 'link_type' => 'internal', ); if ( $edit_id > 0 ) { $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$this->table_name} WHERE keyword = %s AND id <> %d", $keyword, $edit_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( $existing ) { $error_msg = 'Another rule already uses that keyword.'; } else { $wpdb->update( $this->table_name, $update_data, array( 'id' => $edit_id ), array( '%s', '%s', '%s' ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $success_msg = 'Rule updated.'; $edit_rule = null; } } else { $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$this->table_name} WHERE keyword = %s", $keyword ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( $existing ) { $error_msg = 'A rule already uses that keyword.'; } else { $wpdb->insert( $this->table_name, $insert_data, array( '%s', '%s', '%s', '%s', '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $success_msg = 'Rule added.'; } } wp_cache_delete( DAWNAL_CACHE_KEY_RULES ); } } $rules = $wpdb->get_results( "SELECT * FROM {$this->table_name} ORDER BY LENGTH(keyword) DESC, keyword ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $rule_count = is_array( $rules ) ? count( $rules ) : 0; ?>

Free includes unlimited exact-match internal-link rules. Set one keyword or phrase and one internal destination URL per rule.

Free accepts URLs on this WordPress site's domain only.

Rules ()

KeywordDestination URLEnabledActions
keyword ); ?> url ); ?>is_internal_url( $rule->url ) ) : ?> Paid rule inactive in Free enabled === 1 ? 'ON' : 'OFF'; ?> Edit enabled === 1 ? 'Disable' : 'Enable'; ?> Delete
No rules yet. Add your first internal link rule above.

Want control over where all those links appear?

Paid plans add placement controls, heading/title protection, first-paragraph exclusion, per-page limits, keyword variations, external and affiliate links, analytics, CSV tools, styles, randomization, and more.

See what paid unlocks
fs_active && function_exists( 'dawnal_fs' ) && method_exists( dawnal_fs(), 'get_upgrade_url' ) ) ? dawnal_fs()->get_upgrade_url() : 'https://rootlinkd.com/pricing/'; ?>

Upgrade rootlinkd

Free automates exact-match internal links with no placement or volume controls. Paid plans add control, intelligence, monetization, and workflow tools.

FREE

  • Unlimited internal-link rules
  • Exact keyword/phrase only
  • Every eligible match can link
  • Theme-default link appearance

PRO

  • 1 site
  • Placement and volume controls
  • Heading/title-safe behavior
  • Skip first paragraphs
  • External links, variations, styles and per-page disable

BUSINESS

  • 5 sites
  • Affiliate links
  • More keyword variations
  • Analytics and randomizer
  • CSV import/export

DEVELOPER

  • Everything in Business
  • Unlimited sites
  • Unlimited keyword variations
  • Destination URL autocomplete

View Premium Plans

*/ private function get_internal_rules() { global $wpdb; $rules = wp_cache_get( DAWNAL_CACHE_KEY_RULES ); if ( false === $rules ) { $rules = $wpdb->get_results( "SELECT * FROM {$this->table_name} WHERE enabled = 1 ORDER BY LENGTH(keyword) DESC, keyword ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching wp_cache_set( DAWNAL_CACHE_KEY_RULES, $rules, '', DAWNAL_CACHE_TTL ); } if ( empty( $rules ) || ! is_array( $rules ) ) { return array(); } $internal_rules = array(); foreach ( $rules as $rule ) { if ( empty( $rule->keyword ) || empty( $rule->url ) ) { continue; } if ( ! $this->is_internal_url( (string) $rule->url ) ) { continue; } $internal_rules[] = (object) array( 'id' => (int) $rule->id, 'keyword' => (string) $rule->keyword, 'url' => (string) $rule->url, ); } return $internal_rules; } /** * Exact keyword/phrase matcher. Case-insensitive and Unicode aware, while * avoiding matches embedded inside a larger letter/number/underscore token. */ private function keyword_pattern( $keyword ) { return '/(?get_internal_rules(); if ( empty( $rules ) ) { return $content; } if ( $this->dom_available ) { return $this->auto_link_with_dom( $content, $rules ); } return $this->auto_link_with_regex( $content, $rules ); } /** * Link the main singular post/page title. We intentionally avoid archive, * navigation, admin and secondary-loop titles because themes commonly wrap * those in an existing permalink; inserting another would create invalid * nested anchors. On a singular main title, Free has no title-exclusion control. */ public function auto_link_title( $title, $post_id = 0 ) { if ( empty( $title ) || is_admin() || is_feed() ) { return $title; } if ( false !== strpos( $title, 'dal-auto-link' ) || false !== stripos( $title, ' 0 && (int) $post_id > 0 && $queried_id !== (int) $post_id ) { return $title; } } $rules = $this->get_internal_rules(); if ( empty( $rules ) ) { return $title; } return $this->link_plain_text( $title, $rules, true ); } /** * DOM mode links every match of every internal rule. Headings are NOT skipped * in Free. Existing anchors and technical markup are always protected so the * plugin cannot create nested links or corrupt scripts/styles/code. */ private function auto_link_with_dom( $content, $rules ) { try { $dom = new DOMDocument( '1.0', 'UTF-8' ); $previous = libxml_use_internal_errors( true ); $html = '' . $content . ''; $loaded = $dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); if ( ! $loaded ) { return $this->auto_link_with_regex( $content, $rules ); } $xpath = new DOMXPath( $dom ); foreach ( $rules as $rule ) { $keyword = trim( (string) $rule->keyword ); if ( '' === $keyword ) { continue; } $pattern = $this->keyword_pattern( $keyword ); // Re-query after every rule so links inserted by earlier rules are // excluded from subsequent matching. H1-H6 remain eligible. $text_nodes = $xpath->query( '//text()[not(ancestor::a) and not(ancestor::script) and not(ancestor::style) and not(ancestor::code) and not(ancestor::pre) and not(ancestor::textarea)]' ); if ( ! $text_nodes ) { continue; } // Snapshot because the live DOM is modified while iterating. $nodes = array(); foreach ( $text_nodes as $text_node ) { $nodes[] = $text_node; } foreach ( $nodes as $text_node ) { if ( ! $text_node->parentNode ) { continue; } $text = $text_node->nodeValue; if ( ! is_string( $text ) || '' === trim( $text ) ) { continue; } $matched = preg_match_all( $pattern, $text, $matches, PREG_OFFSET_CAPTURE ); if ( ! $matched || empty( $matches[1] ) ) { continue; } $parent = $text_node->parentNode; $cursor = 0; foreach ( $matches[1] as $match ) { $match_text = $match[0]; $match_offset = (int) $match[1]; if ( $match_offset > $cursor ) { $parent->insertBefore( $dom->createTextNode( substr( $text, $cursor, $match_offset - $cursor ) ), $text_node ); } $link = $dom->createElement( 'a' ); // esc_url_raw(): DOMDocument encodes attribute values itself on // saveHTML(). esc_url() here would double-encode "&" in query strings. $link->setAttribute( 'href', esc_url_raw( (string) $rule->url ) ); $link->setAttribute( 'class', 'dal-auto-link' ); $link->appendChild( $dom->createTextNode( $match_text ) ); $parent->insertBefore( $link, $text_node ); $cursor = $match_offset + strlen( $match_text ); } if ( $cursor < strlen( $text ) ) { $parent->insertBefore( $dom->createTextNode( substr( $text, $cursor ) ), $text_node ); } $parent->removeChild( $text_node ); } } $body = $dom->getElementsByTagName( 'body' )->item( 0 ); if ( ! $body ) { return $content; } $final = ''; foreach ( $body->childNodes as $child ) { $final .= $dom->saveHTML( $child ); } return $final ?: $content; } catch ( Throwable $e ) { dawnal_log( 'Free DOM linking error, falling back to regex.', array( 'msg' => $e->getMessage() ) ); return $this->auto_link_with_regex( $content, $rules ); } } /** * Regex fallback tokenizes HTML tags so replacements happen only in text, * never attributes. Existing anchors and technical elements are skipped. * Headings and paragraphs are deliberately NOT skipped in Free. */ private function auto_link_with_regex( $content, $rules ) { $parts = preg_split( '/(||<[^>]+>)/s', $content, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ); if ( false === $parts ) { return $content; } $protected = array( 'a', 'script', 'style', 'code', 'pre', 'textarea' ); $stack = array(); $output = ''; foreach ( $parts as $part ) { if ( isset( $part[0] ) && '<' === $part[0] ) { if ( preg_match( '/^<\s*\/\s*([a-z0-9:-]+)/i', $part, $m ) ) { $tag = strtolower( $m[1] ); if ( in_array( $tag, $protected, true ) ) { for ( $i = count( $stack ) - 1; $i >= 0; $i-- ) { if ( $stack[ $i ] === $tag ) { array_splice( $stack, $i, 1 ); break; } } } } elseif ( preg_match( '/^<\s*([a-z0-9:-]+)/i', $part, $m ) ) { $tag = strtolower( $m[1] ); if ( in_array( $tag, $protected, true ) && ! preg_match( '/\/\s*>$/', $part ) ) { $stack[] = $tag; } } $output .= $part; continue; } $output .= empty( $stack ) ? $this->link_plain_text( $part, $rules, false ) : $part; } return $output; } /** * Link all exact matches in a plain-text fragment. Longer keywords are already * ordered first. Temporary control-character placeholders prevent later rules * from re-linking text already claimed by an earlier rule. */ private function link_plain_text( $text, $rules, $escape_match_text = false ) { if ( '' === $text ) { return $text; } $original_text = $text; $placeholders = array(); $token_seed = 0; foreach ( $rules as $rule ) { $keyword = trim( (string) $rule->keyword ); if ( '' === $keyword ) { continue; } $pattern = $this->keyword_pattern( $keyword ); $text = preg_replace_callback( $pattern, function ( $match ) use ( $rule, $escape_match_text, &$placeholders, &$token_seed ) { $token_seed++; $token = "\x1A" . str_repeat( "\x1B", $token_seed ) . "\x1A"; $label = $escape_match_text ? esc_html( $match[1] ) : $match[1]; $placeholders[ $token ] = '' . $label . ''; return $token; }, $text ); if ( null === $text ) { return $original_text; } } if ( empty( $placeholders ) ) { return $original_text; } // Title context: escape the unmatched portions too, so the whole returned // string is escaped. Placeholder tokens are control characters that // esc_html() leaves intact; the links swapped in afterwards are built from // esc_url() and esc_html() above. if ( $escape_match_text ) { $text = esc_html( $text ); } return strtr( $text, $placeholders ); } }