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 '
';
}
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
);
?>
. */
__( '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() )
);
?>
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 );