array( 'class' => true ), 'div' => array( 'class' => true, 'data-id' => true, 'data-lang' => true, 'data-rule-id' => true, 'data-keyword' => true, 'data-synonyms' => true, 'style' => true, 'title' => true ), 'span' => array( 'class' => true, 'id' => true, 'title' => true, 'style' => true ), 'a' => array( 'href' => true, 'class' => true, 'title' => true, 'target' => true, 'rel' => true, 'data-id' => true ), 'button' => array( 'class' => true, 'id' => true, 'title' => true, 'type' => true, 'aria-expanded' => true, 'data-id' => true, 'data-pilar-id' => true, 'data-rule-id' => true, 'data-keyword' => true ), 'label' => array( 'class' => true ), 'input' => array( 'type' => true, 'class' => true, 'value' => true, 'checked' => true, 'data-id' => true, 'placeholder' => true ), 'svg' => array( 'width' => true, 'height' => true, 'viewbox' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'class' => true, 'xmlns' => true, 'aria-hidden' => true ), 'path' => array( 'd' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true ), 'polygon' => array( 'points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true ), 'polyline' => array( 'points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true ), 'line' => array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true ), 'circle' => array( 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true ), 'rect' => array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true ), ); } public function init() { add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); // Onboarding glue add_action( 'wp_ajax_flink_hide_onboarding', array( $this, 'ajax_hide_onboarding' ) ); add_action( 'admin_init', array( $this, 'maybe_redirect_to_onboarding' ) ); // Settings actions // Pin sidebar highlight to "Enlazado" when the user is on a // legacy URL that we couldn't safely redirect (eg. one with // edit params). WordPress decides which submenu item gets the // active class based on $submenu_file; we override it so the // sidebar always shows the user where they are. add_filter( 'submenu_file', array( $this, 'pin_enlazado_submenu_highlight' ) ); } /** * Force the sidebar's "Enlazado" entry to look active when the * user is rendering one of the legacy hidden pages. Without this, * pages like ?page=flinkil-pilares&edit=12 render fine but the * sidebar shows no active item, which feels broken. */ public function pin_enlazado_submenu_highlight( $submenu_file ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; $legacy = array( 'flinkil-pilares', 'flinkil-reglas', 'flinkil-exclusiones', 'flinkil-exitos', ); if ( in_array( $page, $legacy, true ) ) { return 'flinkil-enlazado'; } // System legacy pages keep the "Sistema" hub (flinkil-ajustes) // highlighted. Herramientas is common; KB is Paid-only. $sistema_legacy = array( 'flinkil-herramientas' ); if ( in_array( $page, $sistema_legacy, true ) ) { return 'flinkil-ajustes'; } return $submenu_file; } /** * AJAX: hide the "Primeros pasos" item from the menu. * Stores the choice in an option; the menu registration reads * it on the next admin page load. Reversible from Ajustes if we * later add a setting for it. */ public function ajax_hide_onboarding() { if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( '', 403 ); check_ajax_referer( 'flink_hide_onboarding', 'nonce' ); update_option( 'flink_onboarding_hidden', '1' ); wp_send_json_success(); } /** * On first activation, redirect once to the onboarding page so * brand-new users land on a guided screen instead of an empty * "Crear contenido". The redirect flag is set by the activation * hook in the main plugin file and consumed (deleted) here. */ public function maybe_redirect_to_onboarding() { if ( ! get_transient( 'flink_onboarding_redirect' ) ) return; delete_transient( 'flink_onboarding_redirect' ); // Don't redirect during AJAX, cron, REST, or bulk plugin activations. if ( wp_doing_ajax() || wp_doing_cron() ) return; if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) return; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( isset( $_GET['activate-multi'] ) ) return; if ( ! current_user_can( 'manage_options' ) ) return; wp_safe_redirect( admin_url( 'admin.php?page=flinkil-onboarding' ) ); exit; } /** * Returns the menu-and-page-layout CSS that needs to apply on every * plugin admin page. Returned as a string so it can be attached to * the main 'flink-admin' style handle via wp_add_inline_style() in * enqueue_assets() — that's the WordPress.org-compliant way to ship * inline styles. */ public function get_wide_screen_css() { return ' #wpcontent { padding-left: 20px !important; } #wpbody-content .wrap { max-width: none !important; padding-right: 24px; } /* Hide the WP auto-generated duplicate first submenu item */ /* Hide the auto-generated duplicate "04 IL" first submenu item */ #toplevel_page_04-internal-linking ul li:first-child, #toplevel_page_04-internal-linking ul li a[href="admin.php?page=04-internal-linking"], /* Hide the default first submenu item that duplicates the parent */ #toplevel_page_04-internal-linking ul li a[href*="page=04-internal-linking"]:not([href*="flinkil-"]) { display: none !important; } /* ── Simple page headers (Ajustes & Herramientas) ── These two pages dropped the illustrated header that the rest of the plugin uses, because they are functional/ technical contexts where the illustration didn\'t fit. Same typographic scale as FLINK_Page_Header (.ilph-title) so the page hierarchy stays consistent across the plugin. */ .il-page .ilset-simple-hd, .il-page .ilh-simple-hd { margin: 0 0 22px; } .il-page .ilset-simple-hd h1, .il-page .ilh-simple-hd h1 { font-size: 28px !important; font-weight: 700 !important; letter-spacing: -.03em !important; line-height: 1.1 !important; margin: 0 0 6px !important; color: var(--ds-ink, #0f1419) !important; } .il-page .ilset-simple-hd p, .il-page .ilh-simple-hd p { font-size: 15px !important; color: var(--ds-ink3, #6b7280) !important; margin: 0 !important; line-height: 1.5 !important; } '; } public function register_menu() { /* * 04 IL * ├─ Crear contenido * ├─ Crear imágenes * ├─ Traducciones * ├─ Automatizaciones * ├─ ─── ENLAZADO ─── * │ ├─ Páginas pilar * │ ├─ Reglas de enlazado * │ ├─ Enlazado semántico * │ └─ Mapa de enlaces * ├─ ─── SISTEMA ─── * │ ├─ Ajustes * │ └─ Herramientas */ add_menu_page( '4Linking', '4Linking', 'manage_options', '4linking-free', '__return_false', 'dashicons-networking', 30 ); // ── Primeros pasos (onboarding) ────────────────────────── // Shown until the user dismisses it via the page itself // (sets il_onboarding_hidden=1). Until then it's the first // item in the submenu — visible to first-time users. if ( get_option( 'flink_onboarding_hidden', '0' ) !== '1' ) { add_submenu_page( '4linking-free', __( 'Getting started', '4linking-free' ), __( 'Getting started', '4linking-free' ), 'manage_options', 'flinkil-onboarding', array( $this, '_render_onboarding' ) ); } else { // Keep it reachable by URL (so the user can re-enable from // a settings link if they ever want to), but hidden from menu. add_submenu_page( null, __( 'Getting started', '4linking-free' ), __( 'Getting started', '4linking-free' ), 'manage_options', 'flinkil-onboarding', array( $this, '_render_onboarding' ) ); } // ── ENLAZADO ───────────────────────────────────────────── // Renders a tabbed interface for the four enlazado views // (pilares / reglas / semantic / mapa). The original sub-pages // are still registered below as hidden pages so deep links // (?page=flinkil-pilares) keep working — critical because URLs // are referenced from emails, error messages and from JS inside // the plugin itself. add_submenu_page( '4linking-free', __( 'Linking', '4linking-free' ), __( 'Linking', '4linking-free' ), 'manage_options', 'flinkil-enlazado', array( $this, '_render_enlazado_hub' ) ); // ── SISTEMA ────────────────────────────────────────────── // Single hub entry with tabs: Bases de conocimiento (PRO), // Ajustes and Herramientas. Reuses the flinkil-ajustes slug as // base so its asset loading keeps working. The hub uses `sec` // (not `tab`) as its query param to avoid colliding with the // internal tabs that the Ajustes page itself uses (?tab=ia, // ?tab=enlazado, ...). add_submenu_page( '4linking-free', __( 'System', '4linking-free' ), __( 'System', '4linking-free' ), 'manage_options', 'flinkil-ajustes', array( $this, '_render_sistema_hub' ) ); // KB, Ajustes and Herramientas keep their own slugs as hidden // routes so deep-links keep resolving. Ajustes shares its slug // with the hub, so it isn't registered separately here. add_submenu_page( null, __( 'Tools', '4linking-free' ), __( 'Tools', '4linking-free' ), 'manage_options', 'flinkil-herramientas', array( $this, 'render_herramientas' ) ); // ── Hidden / direct-URL-only pages ──────────────────────── // The 4 enlazado views are registered as hidden so the existing // URLs (?page=flinkil-pilares etc.) continue to resolve. They're // not shown in the sidebar — the user reaches them via the // tabs inside the "Enlazado" hub. Direct URLs from emails, // error messages and JS code still work because the parent is // null (= no submenu placement, just route registration). add_submenu_page( null, __( 'Pillar pages', '4linking-free' ), __( 'Pillar pages', '4linking-free' ), 'manage_options', 'flinkil-pilares', array( $this, 'render_pilares' ) ); add_submenu_page( null, __( 'Linking rules', '4linking-free' ), __( 'Linking rules', '4linking-free' ),'manage_options', 'flinkil-reglas', array( $this, 'render_reglas' ) ); add_submenu_page( null, __( 'Link map', '4linking-free' ), __( 'Link map', '4linking-free' ), 'manage_options', 'flinkil-exitos', array( $this, '_render_exitos' ) ); add_submenu_page( null, __( 'Excluded URLs', '4linking-free' ), __( 'Excluded URLs', '4linking-free' ), 'manage_options', 'flinkil-exclusiones', array( $this, 'render_exclusiones' ) ); // Redirect bare top-level to the right landing page. // In the full edition the landing page is "Crear contenido" — the // content-generation flow that most users open first. The free // edition doesn't include that module, so it lands on the // Enlazado hub instead, which is its main feature. add_action( 'admin_init', function () { global $pagenow; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( $pagenow !== 'admin.php' || empty( $_GET['page'] ) ) return; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $page = sanitize_key( wp_unslash( $_GET['page'] ) ); if ( $page === '4linking-free' ) { // Full edition lands on Crear contenido; the free edition // (which doesn't ship that module) lands on Enlazado. if ( ! headers_sent() ) wp_safe_redirect( admin_url( 'admin.php?page=flinkil-enlazado' ) ); exit; } // Legacy URL → hub tab redirects. // The four enlazado views are still registered as hidden // pages (so direct loads work and any in-flight bookmark // doesn't 404), but visiting them via the menu / a pasted // link should land on the hub with the matching tab so the // sidebar highlight stays coherent. Only redirect plain // URLs without a query that already names a tab — this // keeps internal callers that pass extra params (eg. // ?page=flinkil-pilares&edit=12) free to opt out by also // passing &skip_hub=1. $legacy_to_tab = array( 'flinkil-pilares' => 'pilares', 'flinkil-reglas' => 'reglas', 'flinkil-exclusiones' => 'excluidas', 'flinkil-exitos' => 'mapa', ); // System pages → Sistema hub sections (uses `sec` param). // Herramientas is common to both editions; KB is Paid-only. $legacy_to_sistema_sec = array( 'flinkil-herramientas' => 'herramientas', ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( isset( $legacy_to_sistema_sec[ $page ] ) && empty( $_GET['skip_hub'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $extras = array_diff_key( $_GET, array( 'page' => 1 ) ); if ( empty( $extras ) ) { wp_safe_redirect( admin_url( 'admin.php?page=flinkil-ajustes&sec=' . $legacy_to_sistema_sec[ $page ] ) ); exit; } } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( isset( $legacy_to_tab[ $page ] ) && empty( $_GET['skip_hub'] ) ) { // Only redirect if the URL is "naked" — no extra params // beyond `page`. This protects deep links like edit // dialogs from being broken by the redirect. // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $extras = array_diff_key( $_GET, array( 'page' => 1 ) ); if ( empty( $extras ) ) { wp_safe_redirect( admin_url( 'admin.php?page=flinkil-enlazado&tab=' . $legacy_to_tab[ $page ] ) ); exit; } } } ); } /** * Hub renderer for the unified "Enlazado" page. Delegates to one * of the four sub-renderers based on the active tab. Each tab keeps * its own URL slug as a query param so deep-linking within the hub * is possible (?page=flinkil-enlazado&tab=reglas). * * The tab strip itself is rendered inline here (not by each * sub-renderer) so we don't touch the four existing render_* * methods. They keep behaving exactly as before — header, body, * everything. We just wrap them in a tab navigation. */ public function _render_enlazado_hub() { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'pilares'; $valid = array( 'pilares', 'reglas', 'excluidas', 'mapa', ); if ( ! in_array( $tab, $valid, true ) ) $tab = 'pilares'; // Each tab carries a small line icon (18×18) for the compact // card-style navigation. Icons follow the Lucide-style stroke // look defined in the brand manual. $tabs = array( 'pilares' => array( 'label' => __( 'Pillar pages', '4linking-free' ), 'method' => 'render_pilares', 'icon' => '', ), 'reglas' => array( 'label' => __( 'Rules', '4linking-free' ), 'method' => 'render_reglas', 'icon' => '', ), 'excluidas' => array( 'label' => __( 'Excluded URLs', '4linking-free' ), 'method' => 'render_exclusiones', 'icon' => '', ), 'mapa' => array( 'label' => __( 'Link map', '4linking-free' ), 'method' => '_render_exitos', 'icon' => '', ), ); // Stash the navigation so the active tab's FLINK_Page_Header // call renders it directly below the section header (option 2: // navigation integrated into the content, not floating on top). self::$pending_hubnav = self::render_hub_nav( 'flinkil-enlazado', $tab, $tabs ); // Wrap in .il-screen so every Enlazado tab shares the same // page background and header position as Crear contenido. echo '
'; // Delegate to the original renderer for the active tab. $method = $tabs[ $tab ]['method']; if ( method_exists( $this, $method ) ) { $this->$method(); } echo '
'; // Clear it afterwards so it never leaks into an unrelated page. self::$pending_hubnav = ''; } /** * Hub renderer for "Sistema". Tabs: Bases de conocimiento (PRO), * Ajustes, Herramientas. Reuses the flinkil-ajustes slug as base and * uses `sec` as its query param (NOT `tab`) so it never collides with * the internal tabs the Ajustes page uses (?tab=ia, ?tab=enlazado...). */ public function _render_sistema_hub() { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $sec = isset( $_GET['sec'] ) ? sanitize_key( wp_unslash( $_GET['sec'] ) ) : 'ajustes'; $tabs = array(); $tabs['ajustes'] = array( 'label' => __( 'Settings', '4linking-free' ), 'method' => 'render_ajustes', 'header_key' => 'ajustes', 'title' => __( 'Settings', '4linking-free' ), 'subtitle' => __( 'Global plugin configuration. Changes apply immediately.', '4linking-free' ), 'icon' => '', ); $tabs['herramientas'] = array( 'label' => __( 'Tools', '4linking-free' ), 'method' => 'render_herramientas', 'header_key' => 'herramientas', 'title' => __( 'Tools', '4linking-free' ), 'subtitle' => __( 'Maintenance, backups and plugin monitoring.', '4linking-free' ), 'icon' => '', ); if ( ! isset( $tabs[ $sec ] ) ) { // Requested section doesn't exist in this edition → fall back // to Ajustes, which is always present (Free and Paid). $sec = isset( $tabs['ajustes'] ) ? 'ajustes' : array_key_first( $tabs ); } // Wrap in .il-screen for the shared page background, matching the // Enlazado and Crear contenido hubs. echo '
'; // Stash the hub navigation so FLINK_Page_Header::render() paints it // right below the section header — same pattern as the other hubs. self::$pending_hubnav = self::render_hub_nav( 'flinkil-ajustes', $sec, $tabs, 'sec' ); // Ajustes and Herramientas don't call Page_Header themselves, so the // hub paints their header here (icon + title + subtitle) for the same // look as Crear contenido. KB has header_key empty because it renders // its own header (and thus the navigation) on its own. if ( ! empty( $tabs[ $sec ]['header_key'] ) ) { FLINK_Page_Header::render( $tabs[ $sec ]['header_key'], $tabs[ $sec ]['title'], $tabs[ $sec ]['subtitle'], '' ); } $method = $tabs[ $sec ]['method']; if ( method_exists( $this, $method ) ) { $this->$method(); } echo '
'; self::$pending_hubnav = ''; } /** * Render the compact card-style tab navigation shared by every hub * (Enlazado, and later Crear contenido / Sistema). Centralised so the * three hubs stay visually identical and a style tweak applies once. * * @param string $page The admin page slug (e.g. 'flinkil-enlazado'). * @param string $active The active tab key. * @param array $tabs Map of key => [ 'label' => ..., 'icon' => ... ]. */ /** * HTML of the hub navigation for the current request, stashed by the * hub renderer so that FLINK_Page_Header::render() can output it right * below the section header. This places the navigation "inside" the * content (after the title) rather than floating above everything. * * @var string */ public static $pending_hubnav = ''; public static function render_hub_nav( $page, $active, $tabs, $param = 'tab' ) { // SVG icons are emitted via wp_kses with an explicit allow-list // (the same one Page_Header uses) so the stroke icons survive // sanitisation instead of being stripped by wp_kses_post. $svg_allowed = array( 'svg' => array( 'viewbox' => true, 'xmlns' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true ), 'path' => array( 'd' => true, 'fill' => true ), 'polygon' => array( 'points' => true ), 'polyline' => array( 'points' => true ), 'circle' => array( 'cx' => true, 'cy' => true, 'r' => true ), 'line' => array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true ), 'rect' => array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true ), ); $html = ''; return $html; } /** Proxy render functions — load module and call its render_page() */ public function _render_onboarding() { // Page-specific CSS and JS — enqueued here rather than from // enqueue_assets() because they're only needed on this page. wp_enqueue_style( 'il-page-onboarding', FLINK_PLUGIN_URL . 'admin/css/page-onboarding.css', array( 'flink-admin' ), filemtime( FLINK_PLUGIN_DIR . 'admin/css/page-onboarding.css' ) ); wp_enqueue_script( 'il-page-onboarding', FLINK_PLUGIN_URL . 'admin/js/page-onboarding.js', array( 'jquery', 'wp-i18n' ), filemtime( FLINK_PLUGIN_DIR . 'admin/js/page-onboarding.js' ), true ); wp_set_script_translations( 'il-page-onboarding', '4linking-free', FLINK_PLUGIN_DIR . 'languages' ); // Pass nonce + redirect URL via wp_localize_script — the // WordPress.org-compliant way to ship PHP values to a JS file. // PRO lands on Crear contenido (the hub); Free has no such page, // so it lands on the Enlazado hub instead. $onboarding_redirect = admin_url( 'admin.php?page=flinkil-enlazado' ); wp_localize_script( 'il-page-onboarding', 'FLINK_ONBOARDING', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'flink_hide_onboarding' ), 'redirectUrl' => $onboarding_redirect, ) ); // The view renders directly — no module needed echo '
'; require FLINK_PLUGIN_DIR . 'views/page-onboarding.php'; echo '
'; } public function _render_exitos() { FLINK_Link_Stats::maybe_install(); wp_enqueue_style( 'il-page-exitos', FLINK_PLUGIN_URL . 'admin/css/page-exitos.css', array( 'flink-admin' ), FLINK_VERSION ); wp_enqueue_script( 'flink-exitos', FLINK_PLUGIN_URL . 'admin/js/exitos.js', array( 'jquery', 'wp-i18n' ), FLINK_VERSION, true ); wp_set_script_translations( 'flink-exitos', '4linking-free', FLINK_PLUGIN_DIR . 'languages' ); wp_localize_script( 'flink-exitos', 'FLINK_EXITOS', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'flink_nonce' ), 'semantic' => class_exists( 'FLINK_Semantic_DB' ) ? 1 : 0, ) ); require FLINK_PLUGIN_DIR . 'includes/views/page-exitos.php'; } private function _get_module( $id ) { global $flink_modules; return isset( $flink_modules[ $id ] ) ? $flink_modules[ $id ] : null; } public function enqueue_assets( $hook ) { // Match any subpage of this plugin regardless of exact hook format WordPress uses $is_plugin_page = ( $hook === 'toplevel_page_04-internal-linking' ) || ( strpos( $hook, 'flinkil-' ) !== false ) || ( strpos( $hook, '4linking-free' ) !== false ) || ( strpos( $hook, 'flinkil-pilares' ) !== false ); if ( ! $is_plugin_page ) return; wp_enqueue_style( 'flink-google-fonts', 'https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap', array(), FLINK_VERSION ); wp_enqueue_style( 'flink-admin', FLINK_PLUGIN_URL . 'admin/css/admin.css', array( 'flink-google-fonts' ), filemtime( FLINK_PLUGIN_DIR . 'admin/css/admin.css' ) ); // Attach the wide-screen + menu CSS as inline style on the same handle. // get_wide_screen_css() returns a hard-coded CSS string with no user // input; wp_add_inline_style() is the WordPress API for injecting // inline CSS and handles output safely (it is not echoed directly). wp_add_inline_style( 'flink-admin', $this->get_wide_screen_css() ); // Inject the shared illustrated-header styles too — these used to // be emitted from a separate admin_head hook. get_styles() likewise // returns a static, hard-coded CSS string. if ( class_exists( 'FLINK_Page_Header' ) ) { wp_add_inline_style( 'flink-admin', FLINK_Page_Header::get_styles() ); } wp_enqueue_script( 'flink-admin', FLINK_PLUGIN_URL . 'admin/js/admin.js', array( 'jquery', 'wp-i18n' ), FLINK_VERSION . '.' . filemtime( FLINK_PLUGIN_DIR . 'admin/js/admin.js' ), true ); wp_set_script_translations( 'flink-admin', '4linking-free', FLINK_PLUGIN_DIR . 'languages' ); // Settings crawl modal — only on the Ajustes page (it depends on // the modal HTML rendered there). if ( strpos( $hook, 'flinkil-ajustes' ) !== false ) { wp_enqueue_script( 'flink-settings-crawl', FLINK_PLUGIN_URL . 'admin/js/settings-crawl.js', array( 'jquery', 'wp-i18n' ), filemtime( FLINK_PLUGIN_DIR . 'admin/js/settings-crawl.js' ), true ); wp_set_script_translations( 'flink-settings-crawl', '4linking-free', FLINK_PLUGIN_DIR . 'languages' ); wp_enqueue_script( 'flink-settings-tipos', FLINK_PLUGIN_URL . 'admin/js/settings-tipos.js', array( 'jquery' ), filemtime( FLINK_PLUGIN_DIR . 'admin/js/settings-tipos.js' ), true ); } // Get active languages from Polylang (if available) for the rule form $active_langs = array(); if ( class_exists( 'FLINK_Polylang_Bridge' ) ) { $active_langs = FLINK_Polylang_Bridge::get_active_languages(); } elseif ( function_exists( 'pll_languages_list' ) ) { $slugs = pll_languages_list( array( 'fields' => 'slug' ) ); $names = pll_languages_list( array( 'fields' => 'name' ) ); if ( is_array( $slugs ) ) { foreach ( $slugs as $i => $slug ) { $active_langs[] = array( 'slug' => $slug, 'name' => ( is_array( $names ) && isset( $names[ $i ] ) ) ? $names[ $i ] : strtoupper( $slug ), ); } } } // Detect site default language from Polylang $default_lang = ''; if ( function_exists( 'pll_default_language' ) ) { $default_lang = pll_default_language(); } elseif ( ! empty( $active_langs ) ) { $default_lang = $active_langs[0]['slug']; } wp_localize_script( 'flink-admin', 'FLINK_DATA', array( 'rulesUrl' => admin_url( 'admin.php?page=flinkil-reglas' ), 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'flink_nonce' ), 'plugin_version' => defined( 'FLINK_VERSION' ) ? FLINK_VERSION : '', // Only a boolean flag is exposed — never the key itself. // The key stays server-side and is consumed only by AJAX // handlers that read it from get_option(). Exposing the // raw key to JS would leak it into the page DOM, browser // extensions and any screenshots of devtools. 'active_langs' => $active_langs, 'default_lang' => $default_lang, 'needs_processing' => 0, // ── Legal URLs for the OpenAI BYOK consent modal (v4.5.0) ── // Auto-selects the English variants when WordPress is // running in any English locale (en_US, en_GB, etc.) so // the modal links to documents the admin can actually // read. Can still be overridden via `il_legal_urls` // filter for non-standard deployments. 'legalUrls' => apply_filters( 'flink_legal_urls', ( 0 === strpos( get_locale(), 'en' ) ) ? array( 'privacy' => 'https://4linking.com/en/privacy-policy/', 'terms' => 'https://4linking.com/en/terms-and-conditions/', ) : array( 'privacy' => 'https://4linking.com/politica-de-privacidad/', 'terms' => 'https://4linking.com/terminos-y-condiciones/', ) ), ) ); } /* ══════════════════════════════════════════════════════════ PÁGINAS PILAR ══════════════════════════════════════════════════════════ */ public function render_pilares() { $pilares_obj = new FLINK_Pilares(); $all_pilares = $pilares_obj->get_all(); $links_map = array(); $total_links = 0; foreach ( $all_pilares as $p ) { $cnt = FLINK_Stats::count_real_links_for_pilar( $p ); $links_map[ $p->id ] = $cnt; $total_links += $cnt; } $total_pilares = count( $all_pilares ); $active_langs = array(); if ( class_exists( 'FLINK_Polylang_Bridge' ) ) { $active_langs = FLINK_Polylang_Bridge::get_active_languages(); } $default_lang = function_exists('pll_default_language') ? pll_default_language() : ( $active_langs[0]['slug'] ?? '' ); // Count per language for chips $per_lang = array(); foreach ( $all_pilares as $p ) { $lg = $p->idioma ?: ''; $per_lang[ $lg ] = ( $per_lang[ $lg ] ?? 0 ) + 1; } ?>
' . '' . esc_html__( 'New pillar page', '4linking-free' ) . ''; FLINK_Page_Header::render( 'pilares', __( 'Pillar pages', '4linking-free' ), __( 'Define the key pages of your content architecture. Linking rules will point to these pages.', '4linking-free' ), $btn ); ?> 0 ? round( $total_links / $total_pilares, 1 ) : 0; ?>

URL
= $page_size ) break; echo wp_kses( $this->render_pilar_row( $p, $links_map[ $p->id ] ?? 0 ), self::admin_kses_allowed() ); $shown++; } ?>
100 ) : ?>
render_pilar_row( $p, 0 ); } public function render_pilar_row( $p, $link_count = 0 ) { $idioma_upper = ! empty( $p->idioma ) ? strtoupper( $p->idioma ) : 'ALL'; ob_start(); ?>
nombre ); ?>
url ); ?>
get_all(); $reglas = $reglas_obj->get_all(); $total_reglas = count( $reglas ); $active_count = count( array_filter( $reglas, function($r){ return (bool)$r->activo; } ) ); $active_langs = array(); if ( class_exists( 'FLINK_Polylang_Bridge' ) ) { $active_langs = FLINK_Polylang_Bridge::get_active_languages(); } elseif ( function_exists( 'pll_languages_list' ) ) { $slugs = pll_languages_list( array( 'fields' => 'slug' ) ); $names = pll_languages_list( array( 'fields' => 'name' ) ); foreach ( (array) $slugs as $i => $slug ) { $active_langs[] = array( 'slug' => $slug, 'name' => $names[$i] ?? strtoupper($slug) ); } } $default_lang = function_exists('pll_default_language') ? pll_default_language() : ( $active_langs[0]['slug'] ?? '' ); // Count per lang for chips $per_lang = array(); foreach ( $reglas as $r ) { $lg = ! empty( $r->idioma ) ? $r->idioma : ( $default_lang ?: '__all__' ); $per_lang[ $lg ] = ( $per_lang[ $lg ] ?? 0 ) + 1; } $kw_set = array_unique( array_column( $reglas, 'nombre' ) ); ?>
' . '' . '' . esc_html__( 'Apply links', '4linking-free' ) . '' . '' . ''; FLINK_Page_Header::render( 'reglas', __( 'Linking rules', '4linking-free' ), __( 'When a keyword appears in any post, it is automatically turned into a link to the URL or pillar page you specify.', '4linking-free' ), $btn ); ?> id ); } $avg_per_rule = $total_reglas > 0 ? round( $total_rule_links / $total_reglas, 1 ) : 0; $active_pct = $total_reglas > 0 ? round( ( $active_count / $total_reglas ) * 100 ) : 0; ?>

render_regla_item( $r, $pilares, $active_langs, $default_lang ), self::admin_kses_allowed() ); ?>
destino_valor ) ) { $pilar_nombre = '—'; $dest_url = $r->destino_valor; $dest_href = esc_url( $r->destino_valor ); } else { foreach ( $pilares as $p ) { if ( (int) $p->id === (int) $r->pilar_id ) { $pilar_nombre = $p->nombre; $dest_url = $p->url; $dest_href = esc_url( $p->url ); break; } } } $keyword = ! empty( $r->nombre ) ? $r->nombre : '—'; $prio = (int) $r->prioridad; $max_links = (int) ( $r->max_enlaces ?? 1 ); $active = (bool) $r->activo; $prio_cls = $prio >= 75 ? 'hi' : ( $prio >= 40 ? 'mid' : 'lo' ); // Synonyms $syn_list = array(); $syn_text = ''; if ( ! empty( $r->descripcion ) ) { $syn_list = array_filter( array_map( 'trim', explode( "\n", $r->descripcion ) ) ); $syn_text = esc_attr( strtolower( $r->descripcion ) ); } // Link count $link_count = (int) FLINK_Stats::count_links_by_rule( $r->id ); $idioma_slug = ! empty( $r->idioma ) ? $r->idioma : ( $default_lang ?: '' ); $idioma_upper = $idioma_slug ? strtoupper( $idioma_slug ) : 'ALL'; ob_start(); ?>
3 ) : ?> +
—
init_for_batch(); $stripped = $linker->strip_links_for_exclusion( $exc_url ); update_option( 'flink_needs_processing', 1, false ); if ( $stripped > 0 ) { $msg = sprintf( /* translators: %d: number of articles from which outbound links were removed. */ _n( 'URL added to exclusions. Outbound links were removed from %d article.', 'URL added to exclusions. Outbound links were removed from %d articles.', $stripped, '4linking-free' ), $stripped ); } else { $msg = __( 'URL added to exclusions.', '4linking-free' ); } $msg_type = 'ok'; } else { $msg_type = 'warn'; $msg = __( 'That URL was already excluded.', '4linking-free' ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. } elseif ( $_POST['flink_exc_action'] === 'remove' && ! empty( $_POST['flink_exc_url_remove'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. FLINK_Exclusiones::remove( sanitize_text_field( wp_unslash( $_POST['flink_exc_url_remove'] ) ) ); $msg_type = 'ok'; update_option( 'flink_needs_processing', 1, false ); $msg = __( 'URL removed from the list. From now on this page will generate outbound links normally again.', '4linking-free' ); } } $exclusiones = FLINK_Exclusiones::get_all(); $count = count( $exclusiones ); ?>
0 ) { $actions = '
' . esc_html( sprintf( /* translators: %d: number of excluded URLs. */ _n( '%d URL excluded', '%d URLs excluded', $count, '4linking-free' ), (int) $count ) ) . '
'; } FLINK_Page_Header::render( 'exclusiones', __( 'Excluded URLs', '4linking-free' ), __( 'Pages you add here will not receive outbound links generated by the plugin. They can still receive inbound links from other articles.', '4linking-free' ), $actions ); ?>

; 2: example full URL in . */ __( 'Relative path %1$s or full URL %2$s', '4linking-free' ), '/my-page/', 'https://...' ), array( 'code' => array() ) ); ?>

' . esc_html__( 'do not insert', '4linking-free' ) . '', '' . esc_html__( 'keep receiving', '4linking-free' ) . '' ), array( 'strong' => array() ) ); ?>

#
$url ) : ?>

. */ __( 'Generates a %s that contains everything you have customized in the plugin.', '4linking-free' ), '.json' ), array( 'code' => array() ) ); ?>

. */ __( 'Upload a %s exported from the plugin. We will show you what it contains and you choose what to restore.', '4linking-free' ), '.json' ), array( 'code' => array() ) ); ?>

. */ __( 'Download all rules in %s format, compatible with Excel, Google Sheets or LibreOffice.', '4linking-free' ), '.csv' ), array( 'code' => array() ) ); ?>

. */ __( 'Upload a %s with the format shown. Rows with the same keyword update the existing rule.', '4linking-free' ), '.csv' ), array( 'code' => array() ) ); ?>

📋

keyword✓
sinonimos
url_destino✓https://web.com/insurance/
idiomaen
prioridad80
max_enlaces2
activo1

💡

array( 'label' => __( 'Linking', '4linking-free' ), 'icon' => '', '_group' => 2, ), 'generales' => array( // Renamed "Generales" → "General" (singular). 'label' => 'General', 'icon' => '', '_group' => 3, ), ); ?>
where the plugin will insert links automatically. Applies to both rules and semantic linking. Recommended: only Post — your pillar pages should stay intact.', '4linking-free' ), array( 'strong' => array(), 'em' => array() ) ); ?>
true), 'objects'); $exclude = array('attachment','revision','nav_menu_item','custom_css','customize_changeset','oembed_cache','wp_block','wp_template','wp_template_part','wp_navigation','wp_font_family','wp_font_face','wp_global_styles'); ?>
name, $exclude)) continue; $checked = in_array($pt->name, $current_arr) ? 'checked' : ''; ?>
toward which links can be generated. For pyramidal SEO architecture, check Post + Page: your blog posts will link to the pillar pages without these being modified.', '4linking-free' ), array( 'strong' => array(), 'em' => array() ) ); ?>
name, $exclude)) continue; $checked = in_array($pt->name, $current_dest_arr) ? 'checked' : ''; ?>
linking rules can insert in a single post. This limit applies only to links generated by rules, not to semantic linking (which has its own limit). Recommended: between 3 and 5.', '4linking-free' ), array( 'strong' => array() ) ); ?>

is not allowed by WordPress.org review. } } add_action( 'admin_init', function() { register_setting( 'flink_settings', 'flink_max_enlaces_total', array( 'type' => 'integer', 'default' => 5, 'sanitize_callback' => 'absint', ) ); register_setting( 'flink_settings', 'flink_origen_tipo', array('type'=>'string','default'=>'post','sanitize_callback'=>'sanitize_text_field') ); // Separate "destino" setting: which post_types can RECEIVE links. // Default 'post,page' implements the SEO-correct topical cluster // pattern out of the box: blog posts link TO pillar pages, but // pillar pages aren't touched by the linking engine. register_setting( 'flink_settings', 'flink_destino_tipo', array('type'=>'string','default'=>'post,page','sanitize_callback'=>'sanitize_text_field') ); // General settings register_setting( 'flink_settings_general', 'flink_delete_data_on_uninstall', array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => function( $val ) { return $val ? 1 : 0; }, ) ); // The onboarding hidden flag is "1 = hidden". The setting field is // labelled positively ("Mostrar guía de Primeros pasos en el menú") // so we invert the user-facing checkbox value when sanitising: // checkbox checked → hidden=0 (show), unchecked → hidden=1 (hide). // The form submits a hidden helper field that always exists so an // unchecked checkbox is detectable. register_setting( 'flink_settings_general', 'flink_onboarding_hidden', array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => function( $val ) { return $val ? 1 : 0; }, ) ); } ); // Handle unchecked checkbox — when form submits without checkbox, set to 0 add_action( 'admin_init', function() { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( isset( $_POST['option_page'] ) && $_POST['option_page'] === 'flink_settings_content' ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( ! isset( $_POST['flink_show_predefined_templates'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $_POST['flink_show_predefined_templates'] = '0'; } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( ! isset( $_POST['flink_disable_comments'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $_POST['flink_disable_comments'] = '0'; } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( ! isset( $_POST['flink_seo_slug_enabled'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $_POST['flink_seo_slug_enabled'] = '0'; } } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( isset( $_POST['option_page'] ) && $_POST['option_page'] === 'flink_settings_general' ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. if ( ! isset( $_POST['flink_delete_data_on_uninstall'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Nonce verified upstream in handler/page bootstrap. Value is sanitized at use site. $_POST['flink_delete_data_on_uninstall'] = '0'; } } }, 5 ); // Flag needs_processing when settings that affect linking are changed add_action( 'update_option_flink_max_enlaces_total', function( $old, $new ) { if ( $old != $new ) { update_option( 'flink_needs_processing', 1, false ); delete_option( 'flink_batch_reviewed' ); } }, 10, 2 ); add_action( 'update_option_flink_origen_tipo', function( $old, $new ) { if ( $old != $new ) { update_option( 'flink_needs_processing', 1, false ); delete_option( 'flink_batch_reviewed' ); } }, 10, 2 );