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, 'title' => true, 'style' => true ),
'a' => array( 'href' => true, 'class' => true, 'title' => true, 'target' => true, 'rel' => true, 'data-id' => true ),
'button' => array( 'class' => true, 'title' => true, 'type' => 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
add_action( 'admin_init', array( $this, 'maybe_delete_openai_key' ) );
// 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-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;
}
/**
* Handles the "Eliminar clave" button in Ajustes → IA.
* Validates nonce + capability, then wipes the option.
* Redirects back to the same tab with a success flag so the
* user gets a clear visual confirmation.
*/
public function maybe_delete_openai_key() {
// 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 ( empty( $_POST['flink_delete_openai_key'] ) ) return;
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Sin permisos suficientes.', 403 );
}
check_admin_referer( 'flink_delete_openai_key', 'flink_delete_openai_key_nonce' );
delete_option( 'flink_openai_key' );
wp_safe_redirect( add_query_arg(
array( 'page' => 'flinkil-ajustes', 'tab' => 'ia', 'key_deleted' => '1' ),
admin_url( 'admin.php' )
) );
exit;
}
/**
* 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',
'04-internal-linking', '__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( '04-internal-linking', 'Primeros pasos', 'Primeros pasos',
'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, 'Primeros pasos', 'Primeros pasos',
'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( '04-internal-linking', 'Enlazado', 'Enlazado',
'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( '04-internal-linking', 'Sistema', 'Sistema',
'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, 'Herramientas', 'Herramientas',
'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, 'Páginas pilar', 'Páginas pilar', 'manage_options', 'flinkil-pilares', array( $this, 'render_pilares' ) );
add_submenu_page( null, 'Reglas de enlazado','Reglas de enlazado','manage_options', 'flinkil-reglas', array( $this, 'render_reglas' ) );
add_submenu_page( null, 'Mapa de enlaces', 'Mapa de enlaces', 'manage_options', 'flinkil-exitos', array( $this, '_render_exitos' ) );
add_submenu_page( null, 'URLs excluidas', 'URLs excluidas', '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 === '04-internal-linking' ) {
// 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-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',
'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' => 'Páginas pilar',
'method' => 'render_pilares',
'icon' => '',
),
'reglas' => array(
'label' => 'Reglas',
'method' => 'render_reglas',
'icon' => '',
),
'mapa' => array(
'label' => 'Mapa de enlaces',
'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' => 'Ajustes',
'method' => 'render_ajustes',
'header_key' => 'ajustes',
'title' => 'Ajustes',
'subtitle' => 'Configuración global del plugin. Los cambios se aplican inmediatamente.',
'icon' => '',
);
$tabs['herramientas'] = array(
'label' => 'Herramientas',
'method' => 'render_herramientas',
'header_key' => 'herramientas',
'title' => 'Herramientas',
'subtitle' => 'Mantenimiento, copias de seguridad y monitorización del plugin.',
'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' ),
filemtime( FLINK_PLUGIN_DIR . 'admin/js/page-onboarding.js' ),
true
);
// 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 '
';
}
FLINK_Page_Header::render(
'exclusiones',
'URLs excluidas',
'Las páginas que añadas aquí no recibirán enlaces salientes generados por el plugin. Sí podrán seguir recibiendo enlaces entrantes desde otros artículos.',
$actions
);
?>
Nota: al excluir una página, sus enlaces salientes se eliminan automáticamente. Si la reactivas, usa "Procesar posts" para regenerarlos.
Los nuevos artículos sí los respetarán, pero los existentes necesitan reprocesarse.
Las páginas excluidas no insertan enlaces salientes hacia páginas pilar. Sí pueden seguir recibiendo enlaces entrantes desde otros artículos. Al excluir una URL, se eliminan los enlaces salientes que esa página ya tenía insertados.
Sin exclusiones activas
El plugin aplicará el enlazado en todos los contenidos publicados
Sube un .csv con el formato indicado. Filas con la misma keyword actualizan la regla existente.
📋 Ver estructura requerida del CSV
La primera fila debe ser la cabecera con estas columnas:
Columna
Obligatorio
Descripción
Ejemplo
keyword
✓
Palabra exacta a enlazar
seguro de salud
sinonimos
Sinónimos separados por | (pipe)
seguro médico|cobertura
url_destino
✓
URL completa de destino
https://web.com/seguros/
idioma
Código de idioma (es, en, fr…)
es
prioridad
Número 1–100. Por defecto: 50
80
max_enlaces
Máx. enlaces por post. Por defecto: 1
2
activo
1 = activa, 0 = desactivada
1
💡 El separador puede ser coma o punto y coma — se detecta automáticamente. Columnas en cualquier orden.
Arrastra el .csv aquí o haz clic
14 ) {
$key_display = substr( $openai_key, 0, 7 )
. str_repeat( '•', 16 )
. substr( $openai_key, -4 );
} else {
// Unusually short key; just show bullets
$key_display = str_repeat( '•', max( 8, $len ) );
}
}
// Default tab is "Módulos" in Paid (showcases the modules system),
// "enlazado" in Free (the only feature configurable from Ajustes).
// The strip script removes the Paid line; Free keeps the fallback.
// 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.
// 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( $active_tab ) ) $active_tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'enlazado' ) );
// Back-compat redirects: keep old bookmarks working after
// tab merges/removals.
if ( $active_tab === 'semantico' ) {
// Old "Enlazado semántico" tab → merged into "Enlazado".
$active_tab = 'enlazado';
}
if ( $active_tab === 'actividad' ) {
// "Actividad" tab was removed entirely.
if ( ! isset( $active_tab ) || $active_tab === 'actividad' ) $active_tab = 'enlazado';
}
$base_url = admin_url( 'admin.php?page=flinkil-ajustes' );
// Each tab has a small SVG icon that fits the vertical sidebar layout.
// Icons are minimal (no fill, stroke 2) to match the rest of the plugin's design system.
//
// Order is intentional and groups tabs by mental model:
// Group 1 (essential): Módulos, OpenAI
// Group 2 (per feature): Enlazado, Contenido e imágenes
// Group 3 (advanced): Cron, General
// The visual separator between groups is rendered in the loop below
// by reading the '_group' marker on each tab.
$tabs = array(
'enlazado' => array(
'label' => 'Enlazado',
'icon' => '',
'_group' => 2,
),
'generales' => array(
// Renamed "Generales" → "General" (singular).
'label' => 'General',
'icon' => '',
'_group' => 3,
),
);
?>
Comportamiento del enlazado
Controla dónde y cuántos enlaces inserta el plugin
Ajustes de enlazado actualizados
El cambio en el número máximo de enlaces afecta a todos los artículos. Para aplicar la nueva configuración es necesario ejecutar un rastreo completo.
Preparando…0 enlaces
Ajustes generales
Configuración general del plugin
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 );