Diferencia entre revisiones de «MediaWiki:Common.js»

De Oroza RO Wiki
Ir a la navegación Ir a la búsqueda
Sin resumen de edición
Sin resumen de edición
 
Línea 224: Línea 224:
       .replace(/[\u0300-\u036f]/g, '')
       .replace(/[\u0300-\u036f]/g, '')
       .replace(/_/g, ' ')
       .replace(/_/g, ' ')
      .replace(/\.{2,}/g, '…')
       .replace(/\s+/g, ' ')
       .replace(/\s+/g, ' ')
       .trim();
       .trim();
Línea 301: Línea 302:
   }
   }
})();
})();


/* =========================================================
/* =========================================================
   OROZA RO UNIVERSAL WIKI SEARCH
   OROZA RO SMART WIKI SEARCH V2.2
   ---------------------------------------------------------
   ---------------------------------------------------------
   - Quick results from sidebar links and categories
   - Searches inside the complete text of every main Wiki page
   - Full title and page-content search through mw.Api()
  - Detects headings/sections and shows the matching fragment
   - Keyboard navigation, debounce, cache, and native fallback
  - Combines a local smart index with MediaWiki native search
   - Adds aliases/synonyms for common Ragnarok Online terms
  - Uses cache to avoid downloading the Wiki on every search
   - Keyboard navigation, mobile support and native fallback
========================================================= */
========================================================= */
(function () {
(function () {
Línea 315: Línea 318:
   var SELECTOR = '[data-oroza-global-search]';
   var SELECTOR = '[data-oroza-global-search]';
   var MIN_QUERY_LENGTH = 2;
   var MIN_QUERY_LENGTH = 2;
  var SMART_LIMIT = 10;
   var API_LIMIT = 8;
   var API_LIMIT = 8;
   var MENU_LIMIT = 5;
   var MENU_LIMIT = 3;
   var DEBOUNCE_MS = 280;
   var DEBOUNCE_MS = 240;
  var INDEX_TTL_MS = 2 * 60 * 60 * 1000;
  var MAX_INDEX_PAGES = 400;
  var MAX_PAGE_TEXT = 60000;
  var CACHE_VERSION = 4;
 
  var indexPromise = null;
  var memoryIndex = null;


   function normalizeText(value) {
   function normalizeText(value) {
Línea 323: Línea 334:
       .toLowerCase()
       .toLowerCase()
       .replace(/_/g, ' ')
       .replace(/_/g, ' ')
      .replace(/ /gi, ' ')
      .replace(/[\u2010-\u2015]/g, '-')
      .replace(/[^a-z0-9@+#áéíóúüñçàèìòùâêîôûäëïöü\s-]/gi, ' ')
       .replace(/\s+/g, ' ')
       .replace(/\s+/g, ' ')
       .trim();
       .trim();
Línea 329: Línea 343:
       text = text.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
       text = text.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
     } catch (e) {}
     } catch (e) {}
    return text;
  }
  function decodeHtmlEntities(value) {
    var text = String(value || '');
    /* MediaWiki search snippets can arrive HTML-escaped more than once.
      Decode repeatedly so <h2> becomes a real tag before removing it. */
    for (var i = 0; i < 3; i++) {
      var decoder = document.createElement('textarea');
      decoder.innerHTML = text;
      var decoded = decoder.value;
      if (decoded === text) break;
      text = decoded;
    }
    return text;
  }
  function cleanSearchCodeResidue(value) {
    var text = String(value || '');
    /* Native MediaWiki snippets can begin or end in the middle of an HTML
      attribute. In that case there is no complete tag for the browser to
      remove, so fragments such as object-fit, drop-shadow or rgba survive.
      This pass removes only presentation/code residue, preserving guide text. */
    var cssProperties = [
      'object-fit', 'bject-fit', 'ject-fit', 'object-position',
      'filter', 'box-shadow', 'text-shadow', 'background', 'background-color',
      'background-image', 'border', 'border-radius', 'border-color',
      'border-width', 'border-style', 'color', 'display', 'position',
      'top', 'right', 'bottom', 'left', 'width', 'height', 'max-width',
      'min-width', 'max-height', 'min-height', 'margin', 'margin-top',
      'margin-right', 'margin-bottom', 'margin-left', 'padding', 'padding-top',
      'padding-right', 'padding-bottom', 'padding-left', 'font-size',
      'font-weight', 'font-family', 'font-style', 'line-height',
      'letter-spacing', 'text-align', 'text-decoration', 'white-space',
      'vertical-align', 'overflow', 'overflow-x', 'overflow-y', 'opacity',
      'transform', 'transform-origin', 'transition', 'animation', 'cursor',
      'z-index', 'float', 'clear', 'content', 'visibility', 'flex',
      'flex-grow', 'flex-shrink', 'flex-basis', 'flex-direction', 'flex-wrap',
      'align-items', 'align-content', 'align-self', 'justify-content',
      'justify-items', 'justify-self', 'gap', 'row-gap', 'column-gap',
      'grid', 'grid-template-columns', 'grid-template-rows', 'grid-column',
      'grid-row', 'list-style', 'list-style-type', 'object-fit'
    ];
    var propertyPattern = new RegExp(
      '(?:^|[\\s"\\\';])(?:' + cssProperties.join('|') + ')\\s*:\\s*[^;{}<>]{0,600};',
      'gi'
    );
    for (var i = 0; i < 4; i++) {
      var before = text;
      text = text.replace(propertyPattern, ' ');
      if (text === before) break;
    }
    /* Remove truncated generic declarations only when they clearly contain a
      CSS value/function. This avoids deleting normal phrases such as
      "Reward: 10 Zeny". */
    var cssValuePattern = /(?:^|[\s"';,.…])[-a-z]{2,}\s*:\s*[^;{}<>]{0,500}(?:rgba?\s*\(|hsla?\s*\(|drop-shadow\s*\(|linear-gradient\s*\(|radial-gradient\s*\(|url\s*\(|calc\s*\(|var\s*\(|\b(?:contain|cover|flex|grid|block|inline-block|absolute|relative|fixed|sticky|hidden|visible|solid|dashed|none|auto|center)\b|\d+(?:\.\d+)?(?:px|rem|em|vh|vw|%))[^;{}<>]*;?/gi;
    for (var j = 0; j < 3; j++) {
      var previous = text;
      text = text.replace(cssValuePattern, ' ');
      if (text === previous) break;
    }
    text = text
      .replace(/\b(?:class|style|id|src|srcset|alt|title|width|height|href|target|rel|loading|decoding|data-[a-z0-9_-]+|aria-[a-z0-9_-]+)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ' ')
      .replace(/\b(?:drop-shadow|rgba?|hsla?|linear-gradient|radial-gradient|translate(?:x|y|3d)?|scale(?:x|y|3d)?|rotate(?:x|y|z|3d)?|calc|var|url)\s*\([^;{}<>]{0,500}\)/gi, ' ')
      .replace(/(?:^|\s)(?:px|rem|em|vh|vw)\b/gi, ' ')
      .replace(/\s*(?:\/?>|<\/?)\s*/g, ' ')
      .replace(/["'`{}]+/g, ' ')
      .replace(/\.{2,}/g, '…')
      .replace(/\s+/g, ' ')
      .trim();


     return text;
     return text;
Línea 334: Línea 428:


   function stripHtml(value) {
   function stripHtml(value) {
     var node = document.createElement('div');
     var text = decodeHtmlEntities(value)
    node.innerHTML = String(value || '');
      .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
     return (node.textContent || node.innerText || '')
      .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
      .replace(/<!--([\s\S]*?)-->/g, ' ');
 
    /* Parse the decoded HTML in a detached element. This removes tags such
      as h2, img, span, table, code and their attributes without executing it. */
    for (var i = 0; i < 2; i++) {
      var node = document.createElement('div');
      node.innerHTML = text;
      var plain = node.textContent || node.innerText || '';
 
      if (plain === text) break;
      text = plain;
    }
 
    /* Final defensive cleanup for malformed or partially escaped markup. */
    text = decodeHtmlEntities(text)
      .replace(/<[^>]*>/g, ' ')
      .replace(/\b(?:class|style|id|src|alt|title|width|height|href|target|rel|loading)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ' ')
      .replace(/(?:^|\s)(?:div|span|table|tbody|thead|tr|td|th|img|h[1-6]|p|br|code|strong|em)(?:\s|$)/gi, ' ');
 
     return cleanSearchCodeResidue(cleanWikiText(text))
      .replace(/\s+/g, ' ')
      .trim();
  }
 
  function cleanWikiText(value) {
    var text = decodeHtmlEntities(value);
 
    text = text
      .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
      .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
      .replace(/<ref\b[^>]*>[\s\S]*?<\/ref>/gi, ' ')
      .replace(/<ref\b[^>]*\/\s*>/gi, ' ')
      .replace(/<!--([\s\S]*?)-->/g, ' ')
      .replace(/\{\|[\s\S]*?\|\}/g, function (table) {
        return table.replace(/[|!{}]/g, ' ');
      })
      .replace(/\[\[Category:[^\]]+\]\]/gi, ' ')
      .replace(/\[\[File:[^\]]+\]\]/gi, ' ')
      .replace(/\[\[Image:[^\]]+\]\]/gi, ' ')
      .replace(/\[\[[^\]|]+\|([^\]]+)\]\]/g, '$1')
      .replace(/\[\[([^\]]+)\]\]/g, '$1')
      .replace(/\[(?:https?:)?\/\/[^\s\]]+\s+([^\]]+)\]/g, '$1')
      .replace(/\[(?:https?:)?\/\/[^\]]+\]/g, ' ')
      .replace(/\{\{[^{}]*\}\}/g, ' ')
      .replace(/\{\{[^{}]*\}\}/g, ' ')
      .replace(/<[^>]+>/g, ' ')
      .replace(/'{2,5}/g, '')
      .replace(/={2,6}/g, ' ')
      .replace(/__[^_]+__/g, ' ')
      .replace(/[|{}]/g, ' ')
       .replace(/\s+/g, ' ')
       .replace(/\s+/g, ' ')
       .trim();
       .trim();
    return cleanSearchCodeResidue(text);
  }
  function extractKeywords(wikitext) {
    var keywords = [];
    var pattern = /<!--\s*OROZA-KEYWORDS\s*:\s*([\s\S]*?)-->/gi;
    var match;
    while ((match = pattern.exec(String(wikitext || '')))) {
      match[1].split(/[,;|\n]/).forEach(function (item) {
        item = item.trim();
        if (item) keywords.push(item);
      });
    }
    return keywords;
  }
  function extractCategories(wikitext) {
    var categories = [];
    var pattern = /\[\[Category\s*:\s*([^\]|]+)(?:\|[^\]]*)?\]\]/gi;
    var match;
    while ((match = pattern.exec(String(wikitext || '')))) {
      var category = cleanWikiText(match[1]);
      if (category && categories.indexOf(category) === -1) categories.push(category);
    }
    return categories;
  }
  function splitIntoSections(wikitext) {
    var source = decodeHtmlEntities(wikitext);
    /* Many Oroza Wiki guides are built with literal HTML instead of == Wiki
      headings ==. Convert HTML headings and accordion summaries into virtual
      Wiki headings so the search can identify the exact section. */
    source = source.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, function (match, level, title) {
      var headingLevel = Math.max(2, Math.min(6, parseInt(level, 10) || 2));
      var marks = '======'.slice(0, headingLevel);
      var cleanTitle = stripHtml(title);
      return cleanTitle ? '\n' + marks + ' ' + cleanTitle + ' ' + marks + '\n' : '\n';
    });
    source = source.replace(/<summary\b[^>]*>([\s\S]*?)<\/summary>/gi, function (match, title) {
      var cleanTitle = stripHtml(title);
      return cleanTitle ? '\n=== ' + cleanTitle + ' ===\n' : '\n';
    });
    var lines = source.split(/\r?\n/);
    var sections = [];
    var currentTitle = '';
    var currentLines = [];
    function flush() {
      var plain = cleanWikiText(currentLines.join('\n'));
      if (plain) {
        sections.push({
          title: cleanWikiText(currentTitle),
          text: plain.slice(0, MAX_PAGE_TEXT)
        });
      }
      currentLines = [];
    }
    lines.forEach(function (line) {
      var heading = line.match(/^\s*(={2,6})\s*(.*?)\s*\1\s*$/);
      if (heading) {
        flush();
        currentTitle = stripHtml(heading[2]);
      } else {
        currentLines.push(line);
      }
    });
    flush();
    if (!sections.length) {
      var plain = cleanWikiText(source);
      if (plain) sections.push({ title: '', text: plain.slice(0, MAX_PAGE_TEXT) });
    }
    return sections;
   }
   }


Línea 349: Línea 577:
         var marker = '/wiki/index.php/';
         var marker = '/wiki/index.php/';
         var markerIndex = url.pathname.indexOf(marker);
         var markerIndex = url.pathname.indexOf(marker);
         if (markerIndex !== -1) {
         if (markerIndex !== -1) title = url.pathname.slice(markerIndex + marker.length);
          title = url.pathname.slice(markerIndex + marker.length);
        }
       }
       }


Línea 365: Línea 591:


     return isEnglish ? {
     return isEnglish ? {
       placeholder: 'Search guides, commands, systems, events, equipment...',
       placeholder: 'Search guides, commands, items, systems and content...',
       ariaLabel: 'Search the entire Oroza RO Wiki',
       ariaLabel: 'Search inside every Oroza RO Wiki guide',
       searchButton: 'Search',
       searchButton: 'Search',
       loading: 'Searching the entire Wiki...',
       loading: 'Searching titles, sections and guide content...',
       menuGroup: 'Quick links by category',
      smartLoading: 'Building the smart guide index for the first search...',
       wikiGroup: 'Results across the Wiki',
       smartGroup: 'Matches found inside guides',
       apiGroup: 'Other Wiki results',
      menuGroup: 'Quick links',
       noResultsTitle: 'No results found',
       noResultsTitle: 'No results found',
       noResultsText: 'Try a shorter term, another spelling, or a related word.',
       noResultsText: 'Try another spelling, a shorter phrase, or a related Ragnarok term.',
       categoryFallback: 'Wiki content',
       categoryFallback: 'Wiki guide',
       sectionPrefix: 'Section',
       sectionPrefix: 'Section',
      insideGuide: 'Inside guide',
      titleMatch: 'Guide title',
      aliasMatch: 'Related term',
       resultSingular: 'result',
       resultSingular: 'result',
       resultPlural: 'results',
       resultPlural: 'results',
       viewAll: 'View all results',
       viewAll: 'View native search',
       suggestion: 'Did you mean',
       suggestion: 'Did you mean',
       apiErrorTitle: 'Live search is temporarily unavailable',
      indexedGuides: 'guides indexed',
       apiErrorText: 'You can still press Enter or use the Search button to open MediaWiki search.'
      indexUnavailable: 'Smart index unavailable; showing native results.',
       apiErrorTitle: 'Native search is temporarily unavailable',
       apiErrorText: 'Smart guide matches are still available. Press Enter to open MediaWiki search.',
      introduction: 'Introduction'
     } : {
     } : {
       placeholder: 'Buscar guías, comandos, sistemas, eventos, equipo...',
       placeholder: 'Buscar guías, comandos, objetos, sistemas y contenido...',
       ariaLabel: 'Buscar en toda la Wiki de Oroza RO',
       ariaLabel: 'Buscar dentro de todas las guías de la Wiki de Oroza RO',
       searchButton: 'Buscar',
       searchButton: 'Buscar',
       loading: 'Buscando en toda la Wiki...',
       loading: 'Buscando en títulos, secciones y contenido de las guías...',
       menuGroup: 'Accesos rápidos por categoría',
       smartLoading: 'Creando el índice inteligente de las guías por primera vez...',
       wikiGroup: 'Resultados en toda la Wiki',
      smartGroup: 'Coincidencias encontradas dentro de las guías',
       apiGroup: 'Otros resultados de la Wiki',
      menuGroup: 'Accesos rápidos',
       noResultsTitle: 'No encontramos resultados',
       noResultsTitle: 'No encontramos resultados',
       noResultsText: 'Prueba con una palabra más corta, otra escritura o un término relacionado.',
       noResultsText: 'Prueba otra escritura, una frase más corta o un término relacionado de Ragnarok.',
       categoryFallback: 'Contenido de la Wiki',
       categoryFallback: 'Guía de la Wiki',
       sectionPrefix: 'Sección',
       sectionPrefix: 'Sección',
      insideGuide: 'Dentro de la guía',
      titleMatch: 'Título de la guía',
      aliasMatch: 'Término relacionado',
       resultSingular: 'resultado',
       resultSingular: 'resultado',
       resultPlural: 'resultados',
       resultPlural: 'resultados',
       viewAll: 'Ver todos los resultados',
       viewAll: 'Ver búsqueda nativa',
       suggestion: 'Quizá quisiste decir',
       suggestion: 'Quizá quisiste decir',
       apiErrorTitle: 'La búsqueda instantánea no está disponible temporalmente',
      indexedGuides: 'guías indexadas',
       apiErrorText: 'Aun puedes presionar Enter o usar el botón Buscar para abrir la búsqueda nativa de MediaWiki.'
      indexUnavailable: 'Índice inteligente no disponible; se muestran resultados nativos.',
       apiErrorTitle: 'La búsqueda nativa no está disponible temporalmente',
       apiErrorText: 'Las coincidencias del índice inteligente siguen disponibles. Presiona Enter para abrir la búsqueda de MediaWiki.',
      introduction: 'Introducción'
    };
  }
 
  function getSynonymMap(isEnglish) {
    var shared = {
      '@aa': ['auto attack', 'auto ataque', 'autoatk', 'automatic attack'],
      'aa': ['auto attack', 'auto ataque', 'autoatk'],
      'bg': ['battleground', 'battlegrounds', 'campo de batalla'],
      'woe': ['war of emperium', 'guild war', 'guerra de emperium'],
      'mvp': ['boss', 'jefe', 'monster boss'],
      'npc': ['non player character', 'personaje no jugador'],
      'exp': ['experience', 'experiencia'],
      'zeny': ['money', 'currency', 'dinero', 'moneda']
    };
 
    var english = {
      'beginner': ['starter', 'new player', 'newbie', 'first steps', 'getting started'],
      'newbie': ['beginner', 'starter', 'new player', 'first steps'],
      'starter': ['beginner', 'new player', 'newbie', 'first steps', '@starter'],
      'job': ['class', 'profession', 'job change'],
      'class': ['job', 'profession', 'job change'],
      'card': ['cards', 'monster card'],
      'cards': ['card', 'monster cards'],
      'hat': ['headgear', 'head gear', 'costume'],
      'headgear': ['hat', 'head gear', 'costume'],
      'refine': ['refinement', 'upgrade', 'safe refine'],
      'refinement': ['refine', 'upgrade', 'safe refine'],
      'enchant': ['enchantment', 'bonus', 'random option'],
      'quest': ['mission', 'task'],
      'mission': ['quest', 'task'],
      'party': ['group', 'even share'],
      'group': ['party', 'even share'],
      'drop': ['loot', 'reward', 'item drop'],
      'loot': ['drop', 'reward'],
      'pet': ['pokemon', 'companion', 'homunculus'],
      'pokemon': ['pet', 'companion'],
      'command': ['commands', '@command', 'chat command'],
      'donation': ['donate', 'cash points', 'support server'],
      'event': ['events', 'activity'],
      'skill': ['skills', 'ability'],
      'equipment': ['gear', 'weapon', 'armor'],
      'gear': ['equipment', 'weapon', 'armor']
    };
 
    var spanish = {
      'principiante': ['starter', 'jugador nuevo', 'novato', 'primeros pasos', 'inicio'],
      'novato': ['principiante', 'starter', 'jugador nuevo', 'primeros pasos'],
      'nuevo': ['principiante', 'starter', 'jugador nuevo', 'primeros pasos'],
      'starter': ['principiante', 'jugador nuevo', 'novato', 'primeros pasos', '@starter'],
      'job': ['clase', 'profesión', 'cambio de job'],
      'clase': ['job', 'profesión', 'cambio de clase'],
      'carta': ['cartas', 'card', 'monster card'],
      'cartas': ['carta', 'cards', 'monster cards'],
      'hat': ['sombrero', 'headgear', 'costume'],
      'sombrero': ['hat', 'headgear', 'costume'],
      'refinar': ['refinamiento', 'mejorar equipo', 'refine'],
      'refinamiento': ['refinar', 'mejorar equipo', 'safe refine'],
      'encantar': ['encantamiento', 'bonus', 'opción aleatoria'],
      'encantamiento': ['encantar', 'bonus', 'random option'],
      'quest': ['misión', 'mision', 'tarea'],
      'misión': ['quest', 'mision', 'tarea'],
      'mision': ['quest', 'misión', 'tarea'],
      'party': ['grupo', 'even share', 'compartir experiencia'],
      'grupo': ['party', 'even share', 'compartir experiencia'],
      'drop': ['loot', 'recompensa', 'objeto'],
      'loot': ['drop', 'recompensa'],
      'mascota': ['pokemon', 'pet', 'homúnculo', 'homunculo'],
      'pokemon': ['mascota', 'pet', 'compañero'],
      'comando': ['comandos', '@comando', 'chat command'],
      'donación': ['donar', 'cash points', 'apoyar servidor'],
      'donacion': ['donar', 'cash points', 'apoyar servidor'],
      'evento': ['eventos', 'actividad'],
      'skill': ['skills', 'habilidad'],
      'equipo': ['gear', 'arma', 'armadura']
    };
 
    var result = {};
    Object.keys(shared).forEach(function (key) { result[normalizeText(key)] = shared[key]; });
    Object.keys(isEnglish ? english : spanish).forEach(function (key) {
      result[normalizeText(key)] = (isEnglish ? english : spanish)[key];
    });
    return result;
  }
 
  function tokenize(value) {
    var stopWords = {
      'the': 1, 'a': 1, 'an': 1, 'and': 1, 'or': 1, 'of': 1, 'to': 1, 'in': 1, 'for': 1,
      'el': 1, 'la': 1, 'los': 1, 'las': 1, 'un': 1, 'una': 1, 'y': 1, 'o': 1, 'de': 1,
      'del': 1, 'en': 1, 'para': 1, 'como': 1, 'how': 1, 'what': 1, 'que': 1
    };
 
    return normalizeText(value).split(' ').filter(function (token) {
      return token && (token.length >= 2 || token.charAt(0) === '@') && !stopWords[token];
    });
  }
 
  function unique(values) {
    var seen = Object.create(null);
    return values.filter(function (value) {
      var key = normalizeText(value);
      if (!key || seen[key]) return false;
      seen[key] = true;
      return true;
    });
  }
 
  function expandQuery(query, synonymMap) {
    var originalTokens = tokenize(query);
    var expandedPhrases = [];
 
    originalTokens.forEach(function (token) {
      var synonyms = synonymMap[token] || [];
      synonyms.forEach(function (synonym) {
        expandedPhrases.push(synonym);
      });
    });
 
    return {
      phrase: normalizeText(query),
      originalTokens: unique(originalTokens),
      expandedTokens: unique(expandedPhrases.reduce(function (all, phrase) {
        return all.concat(tokenize(phrase));
      }, [])),
      expandedPhrases: unique(expandedPhrases.map(normalizeText))
     };
     };
   }
   }
Línea 408: Línea 774:
       var category = (titleNode.textContent || '').replace(/\s+/g, ' ').trim();
       var category = (titleNode.textContent || '').replace(/\s+/g, ' ').trim();
       var list = titleNode.nextElementSibling;
       var list = titleNode.nextElementSibling;
       if (!list || String(list.tagName).toLowerCase() !== 'ul') return;
       if (!list || String(list.tagName).toLowerCase() !== 'ul') return;


       var links = list.querySelectorAll('a[href]');
       Array.prototype.forEach.call(list.querySelectorAll('a[href]'), function (link) {
      Array.prototype.forEach.call(links, function (link) {
         var title = decodePageName(link.getAttribute('href'));
         var title = decodePageName(link.getAttribute('href'));
         var labelNode = link.querySelector('.menu-text');
         var labelNode = link.querySelector('.menu-text');
Línea 419: Línea 783:
         label = String(label || '').replace(/›/g, '').replace(/\s+/g, ' ').trim();
         label = String(label || '').replace(/›/g, '').replace(/\s+/g, ' ').trim();
         title = title || label;
         title = title || label;
         if (!title || !link.href) return;
         if (!title || !link.href) return;


Línea 439: Línea 802:
     if (!normalizedQuery) return [];
     if (!normalizedQuery) return [];


     return entries
     return entries.map(function (entry) {
      .map(function (entry) {
      var normalizedTitle = normalizeText(entry.title + ' ' + entry.label);
        var normalizedTitle = normalizeText(entry.title + ' ' + entry.label);
      var score = 0;
        var score = 0;
      if (normalizedTitle === normalizedQuery) score = 100;
 
      else if (normalizedTitle.indexOf(normalizedQuery) === 0) score = 80;
        if (normalizedTitle === normalizedQuery) score = 100;
      else if (normalizedTitle.indexOf(normalizedQuery) !== -1) score = 60;
        else if (normalizedTitle.indexOf(normalizedQuery) === 0) score = 80;
      else if (entry.normalized.indexOf(normalizedQuery) !== -1) score = 40;
        else if (entry.normalized.indexOf(normalizedQuery) !== -1) score = 50;
      return { entry: entry, score: score };
 
    }).filter(function (item) {
        return { entry: entry, score: score };
      return item.score > 0;
      })
    }).sort(function (a, b) {
      .filter(function (item) { return item.score > 0; })
      return b.score - a.score || a.entry.label.localeCompare(b.entry.label);
      .sort(function (a, b) {
    }).slice(0, MENU_LIMIT).map(function (item) {
        if (b.score !== a.score) return b.score - a.score;
      return item.entry;
        return a.entry.label.localeCompare(b.entry.label);
    });
      })
      .slice(0, MENU_LIMIT)
      .map(function (item) { return item.entry; });
   }
   }


   function buildCategoryMap(entries) {
   function buildCategoryMap(entries) {
     var map = Object.create(null);
     var map = Object.create(null);
     entries.forEach(function (entry) {
     entries.forEach(function (entry) {
       map[normalizeText(entry.title)] = entry.category;
       map[normalizeText(entry.title)] = entry.category;
       map[normalizeText(entry.label)] = entry.category;
       map[normalizeText(entry.label)] = entry.category;
     });
     });
    return map;
  }
  function getCacheKey() {
    return 'oroza-smart-wiki-index-v' + CACHE_VERSION + ':' + window.location.hostname;
  }
  function readIndexCache() {
    try {
      var raw = window.localStorage.getItem(getCacheKey());
      if (!raw) return null;
      var payload = JSON.parse(raw);
      if (!payload || !Array.isArray(payload.pages) || !payload.createdAt) return null;
      if (Date.now() - payload.createdAt > INDEX_TTL_MS) return null;
      return payload.pages;
    } catch (e) {
      return null;
    }
  }
  function writeIndexCache(pages) {
    try {
      window.localStorage.setItem(getCacheKey(), JSON.stringify({
        createdAt: Date.now(),
        pages: pages
      }));
    } catch (e) {}
  }
  function clearIndexCache() {
    try { window.localStorage.removeItem(getCacheKey()); } catch (e) {}
    memoryIndex = null;
    indexPromise = null;
  }
  function getRevisionContent(page) {
    var revision = page && page.revisions && page.revisions[0];
    if (!revision) return '';
    if (revision.slots && revision.slots.main) {
      return revision.slots.main.content || revision.slots.main['*'] || '';
    }
    return revision.content || revision['*'] || '';
  }
  function createIndexedPage(page, categoryMap) {
    var wikitext = getRevisionContent(page);
    var title = page.title || '';
    var sections = splitIntoSections(wikitext);
    var keywords = extractKeywords(wikitext);
    var categories = extractCategories(wikitext);
    var category = categoryMap[normalizeText(title)] || categories[0] || '';
    return {
      title: title,
      normalizedTitle: normalizeText(title),
      url: (window.mw && mw.util) ? mw.util.getUrl(title) : '/wiki/index.php?title=' + encodeURIComponent(title.replace(/ /g, '_')),
      category: category,
      keywords: keywords,
      normalizedKeywords: normalizeText(keywords.join(' ')),
      sections: sections.map(function (section) {
        return {
          title: section.title,
          normalizedTitle: normalizeText(section.title),
          text: section.text,
          normalizedText: normalizeText(section.text)
        };
      })
    };
  }
  function buildSmartIndex(api, categoryMap) {
    var pages = [];
    var continueParams = {};
    function fetchBatch() {
      var params = {
        action: 'query',
        generator: 'allpages',
        gapnamespace: 0,
        gapfilterredir: 'nonredirects',
        gaplimit: 'max',
        prop: 'revisions',
        rvprop: 'content',
        rvslots: 'main',
        rvlimit: 1,
        formatversion: 2,
        utf8: 1
      };


     return map;
      Object.keys(continueParams).forEach(function (key) {
        params[key] = continueParams[key];
      });
 
      return api.get(params).then(function (data) {
        var batch = data && data.query && Array.isArray(data.query.pages) ? data.query.pages : [];
 
        batch.forEach(function (page) {
          if (pages.length >= MAX_INDEX_PAGES || page.missing) return;
          pages.push(createIndexedPage(page, categoryMap));
        });
 
        if (data && data.continue && pages.length < MAX_INDEX_PAGES) {
          continueParams = data.continue;
          return fetchBatch();
        }
 
        writeIndexCache(pages);
        memoryIndex = pages;
        return pages;
      });
    }
 
    return fetchBatch();
  }
 
  function ensureSmartIndex(api, categoryMap) {
    if (memoryIndex) return Promise.resolve(memoryIndex);
 
    var cached = readIndexCache();
    if (cached) {
      memoryIndex = cached;
      return Promise.resolve(cached);
    }
 
    if (!api) return Promise.reject(new Error('MediaWiki API unavailable'));
    if (!indexPromise) {
      indexPromise = buildSmartIndex(api, categoryMap).catch(function (error) {
        indexPromise = null;
        throw error;
      });
    }
    return indexPromise;
  }
 
  function countOccurrences(text, token) {
    if (!token) return 0;
    var count = 0;
    var position = 0;
    while ((position = text.indexOf(token, position)) !== -1) {
      count++;
      position += Math.max(token.length, 1);
      if (count >= 8) break;
    }
     return count;
  }
 
  function makeSnippet(text, queryData, maxLength) {
    var plain = cleanSearchCodeResidue(stripHtml(text)).replace(/\s+/g, ' ').trim();
    if (!plain) return '';
 
    var lower = plain.toLowerCase();
    var candidates = [String(queryData.raw || '').toLowerCase()]
      .concat(queryData.originalTokens || [])
      .concat(queryData.expandedTokens || []);
    var position = -1;
 
    candidates.some(function (candidate) {
      if (!candidate) return false;
      position = lower.indexOf(candidate.toLowerCase());
      return position !== -1;
    });
 
    if (position < 0) position = 0;
    var radius = Math.floor((maxLength || 220) / 2);
    var start = Math.max(0, position - radius);
    var end = Math.min(plain.length, start + (maxLength || 220));
 
    if (end - start < (maxLength || 220) && start > 0) {
      start = Math.max(0, end - (maxLength || 220));
    }
 
    var snippet = cleanSearchCodeResidue(plain.slice(start, end)).trim();
    if (!snippet) return '';
    if (start > 0) snippet = '…' + snippet;
    if (end < plain.length) snippet += '…';
    return snippet;
  }
 
  function scoreSection(page, section, queryData) {
    var title = page.normalizedTitle;
    var sectionTitle = section.normalizedTitle;
    var text = section.normalizedText;
    var keywords = page.normalizedKeywords;
    var phrase = queryData.phrase;
    var originalTokens = queryData.originalTokens;
    var expandedTokens = queryData.expandedTokens;
    var score = 0;
    var matchType = '';
 
    if (title === phrase) {
      score += 320;
      matchType = 'title';
    } else if (title.indexOf(phrase) === 0) {
      score += 260;
      matchType = 'title';
    } else if (title.indexOf(phrase) !== -1) {
      score += 220;
      matchType = 'title';
    }
 
    if (keywords && phrase && keywords.indexOf(phrase) !== -1) {
      score += 190;
      if (!matchType) matchType = 'alias';
    }
 
    if (sectionTitle === phrase) {
      score += 185;
      if (!matchType) matchType = 'section';
    } else if (sectionTitle && sectionTitle.indexOf(phrase) !== -1) {
      score += 160;
      if (!matchType) matchType = 'section';
    }
 
    if (phrase && text.indexOf(phrase) !== -1) {
      score += 135;
      if (!matchType) matchType = 'content';
    }
 
    var originalMatches = 0;
    var expandedMatches = 0;
    var frequency = 0;
 
    originalTokens.forEach(function (token) {
      var inTitle = title.indexOf(token) !== -1;
      var inSection = sectionTitle.indexOf(token) !== -1;
      var inKeywords = keywords.indexOf(token) !== -1;
      var occurrences = countOccurrences(text, token);
 
      if (inTitle || inSection || inKeywords || occurrences) originalMatches++;
      if (inTitle) score += 24;
      if (inSection) score += 19;
      if (inKeywords) score += 17;
      if (occurrences) score += Math.min(occurrences, 5) * 7;
      frequency += occurrences;
    });
 
    expandedTokens.forEach(function (token) {
      if (title.indexOf(token) !== -1 || sectionTitle.indexOf(token) !== -1 || keywords.indexOf(token) !== -1 || text.indexOf(token) !== -1) {
        expandedMatches++;
      }
    });
 
    var coverage = originalTokens.length ? originalMatches / originalTokens.length : 0;
    if (originalTokens.length > 1 && originalMatches === originalTokens.length) score += 65;
    score += Math.round(coverage * 50);
    score += Math.min(expandedMatches, 4) * 8;
    score += Math.min(frequency, 8) * 2;
 
    var qualifies = false;
    if (phrase && (title.indexOf(phrase) !== -1 || sectionTitle.indexOf(phrase) !== -1 || keywords.indexOf(phrase) !== -1 || text.indexOf(phrase) !== -1)) {
      qualifies = true;
    } else if (originalTokens.length === 1) {
      qualifies = originalMatches === 1 || expandedMatches > 0;
    } else if (originalTokens.length > 1) {
      qualifies = coverage >= 0.67 || (coverage >= 0.5 && expandedMatches > 0);
    }
 
    return {
      qualifies: qualifies,
      score: score,
      matchType: matchType || (expandedMatches ? 'alias' : 'content')
    };
  }
 
  function searchSmartIndex(index, rawQuery, labels, synonymMap) {
    var queryData = expandQuery(rawQuery, synonymMap);
    queryData.raw = rawQuery;
    var results = [];
 
    (index || []).forEach(function (page) {
      var best = null;
 
      (page.sections || []).forEach(function (section) {
        var scored = scoreSection(page, section, queryData);
        if (!scored.qualifies) return;
        if (!best || scored.score > best.score) {
          best = {
            score: scored.score,
            matchType: scored.matchType,
            section: section
          };
        }
      });
 
      if (!best) return;
 
      var location = labels.insideGuide;
      if (best.matchType === 'title') location = labels.titleMatch;
      else if (best.matchType === 'alias') location = labels.aliasMatch;
 
      if (best.section.title) {
        location += ' · ' + labels.sectionPrefix + ': ' + best.section.title;
      }
 
      results.push({
        title: page.title,
        label: page.title,
        href: page.url,
        category: page.category || labels.categoryFallback,
        location: location,
        snippet: makeSnippet(best.section.text, queryData, 240),
        score: best.score
      });
    });
 
    return results.sort(function (a, b) {
      return b.score - a.score || a.title.localeCompare(b.title);
    }).slice(0, SMART_LIMIT);
  }
 
 
 
  function ensureSearchHighlightStyles() {
    if (document.getElementById('oroza-search-highlight-styles')) return;
 
    var style = document.createElement('style');
    style.id = 'oroza-search-highlight-styles';
    style.textContent =
      '.oroza-search-highlight{' +
        'display:inline;' +
        'padding:1px 3px;' +
        'border-radius:4px;' +
        'background:linear-gradient(180deg,#fff0a6 0%,#ffd84d 100%);' +
        'color:#17324f!important;' +
        'font-weight:800;' +
        'box-shadow:inset 0 -1px 0 rgba(185,132,0,.22);' +
        'text-decoration:none;' +
      '}' +
      '.oroza-search-result-title .oroza-search-highlight{' +
        'padding:1px 4px;' +
        'background:linear-gradient(180deg,#ffe982 0%,#ffc928 100%);' +
      '}' +
      '.oroza-search-result-link[aria-selected="true"] .oroza-search-highlight{' +
        'background:#fff4bd;' +
        'color:#132a43!important;' +
      '}' +
      '@media (prefers-reduced-motion:reduce){' +
        '.oroza-search-highlight{transition:none;}' +
      '}';
 
    (document.head || document.documentElement).appendChild(style);
  }
 
  function foldTextWithMap(value) {
    var source = String(value || '');
    var folded = '';
    var map = [];
 
    for (var i = 0; i < source.length; i++) {
      var character = source.charAt(i);
      var normalized = character;
 
      try {
        normalized = character.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
      } catch (e) {}
 
      normalized = normalized
        .toLowerCase()
        .replace(/[\u2010-\u2015]/g, '-');
 
      for (var j = 0; j < normalized.length; j++) {
        folded += normalized.charAt(j);
        map.push(i);
      }
    }
 
    return { text: folded, map: map };
  }
 
  function getHighlightTerms(query) {
    var raw = String(query || '').replace(/\s+/g, ' ').trim();
    if (!raw) return [];
 
    var terms = [];
    var seen = Object.create(null);
 
    function addTerm(term) {
      term = String(term || '').trim();
      if (!term) return;
 
      var key = normalizeText(term);
      if (!key || seen[key]) return;
 
      seen[key] = true;
      terms.push(term);
    }
 
    addTerm(raw);
 
    var tokens = raw.split(/\s+/);
    tokens.forEach(function (token) {
      var clean = token.replace(/^[^\w@+#áéíóúüñçàèìòùâêîôûäëïöü]+|[^\w@+#áéíóúüñçàèìòùâêîôûäëïöü]+$/gi, '');
      if (!clean) return;
 
      /* Avoid painting common two-letter words in long questions, but keep
        useful short game terms such as IA, BG, HP or SP when searched alone. */
      if (clean.length >= 3 || tokens.length === 1) addTerm(clean);
    });
 
    return terms.sort(function (a, b) { return b.length - a.length; });
  }
 
  function findHighlightRanges(text, query) {
    var source = String(text || '');
    var terms = getHighlightTerms(query);
    if (!source || !terms.length) return [];
 
    var foldedSource = foldTextWithMap(source);
    var ranges = [];
 
    terms.forEach(function (term) {
      var foldedTerm = foldTextWithMap(term).text;
      if (!foldedTerm) return;
 
      var position = 0;
      while ((position = foldedSource.text.indexOf(foldedTerm, position)) !== -1) {
        var start = foldedSource.map[position];
        var lastMapIndex = position + foldedTerm.length - 1;
        var end = typeof foldedSource.map[lastMapIndex] === 'number'
          ? foldedSource.map[lastMapIndex] + 1
          : start + term.length;
 
        ranges.push({ start: start, end: end });
        position += Math.max(foldedTerm.length, 1);
 
        if (ranges.length >= 60) return;
      }
    });
 
    ranges.sort(function (a, b) {
      return a.start - b.start || b.end - a.end;
    });
 
    var merged = [];
    ranges.forEach(function (range) {
      var previous = merged.length ? merged[merged.length - 1] : null;
 
      if (!previous || range.start > previous.end) {
        merged.push({ start: range.start, end: range.end });
      } else if (range.end > previous.end) {
        previous.end = range.end;
      }
    });
 
    return merged;
  }
 
  function appendHighlightedText(parent, text, query) {
    var source = String(text || '');
    var ranges = findHighlightRanges(source, query);
 
    if (!ranges.length) {
      parent.appendChild(document.createTextNode(source));
      return parent;
    }
 
    var cursor = 0;
    ranges.forEach(function (range) {
      if (range.start > cursor) {
        parent.appendChild(document.createTextNode(source.slice(cursor, range.start)));
      }
 
      var mark = document.createElement('mark');
      mark.className = 'oroza-search-highlight';
      mark.textContent = source.slice(range.start, range.end);
      parent.appendChild(mark);
      cursor = range.end;
    });
 
    if (cursor < source.length) {
      parent.appendChild(document.createTextNode(source.slice(cursor)));
    }
 
    return parent;
   }
   }


Línea 479: Línea 1310:
   function initSearch(root) {
   function initSearch(root) {
     var widget = root.querySelector ? root.querySelector(SELECTOR) : null;
     var widget = root.querySelector ? root.querySelector(SELECTOR) : null;
     if (!widget || widget.dataset.orozaReady === '1') return;
     if (!widget || widget.dataset.orozaSmartReady === '23') return;
 
     widget.dataset.orozaSmartReady = '23';
     widget.dataset.orozaReady = '1';


     var form = widget.querySelector('.oroza-global-search-form');
     var form = widget.querySelector('.oroza-global-search-form');
Línea 488: Línea 1318:
     var clearButton = widget.querySelector('[data-oroza-search-clear]');
     var clearButton = widget.querySelector('[data-oroza-search-clear]');
     var submitText = widget.querySelector('.oroza-global-search-submit span:last-child');
     var submitText = widget.querySelector('.oroza-global-search-submit span:last-child');
    var helper = widget.querySelector('.oroza-global-search-helper');


     if (!form || !input || !results) return;
     if (!form || !input || !results || !clearButton) return;
 
    ensureSearchHighlightStyles();


     var labels = getLocale();
     var labels = getLocale();
    var isEnglish = window.location.hostname === 'en.orozaro.com';
    var synonymMap = getSynonymMap(isEnglish);
     var menuEntries = collectMenuEntries();
     var menuEntries = collectMenuEntries();
     var categoryMap = buildCategoryMap(menuEntries);
     var categoryMap = buildCategoryMap(menuEntries);
     var cache = Object.create(null);
     var apiCache = Object.create(null);
     var debounceTimer = null;
     var debounceTimer = null;
     var requestSequence = 0;
     var requestSequence = 0;
Línea 503: Línea 1338:
     input.setAttribute('aria-label', labels.ariaLabel);
     input.setAttribute('aria-label', labels.ariaLabel);
     if (submitText) submitText.textContent = labels.searchButton;
     if (submitText) submitText.textContent = labels.searchButton;
    if (helper) {
      helper.innerHTML = isEnglish
        ? 'Searches inside every guide, section and paragraph. Use <kbd>↑</kbd> <kbd>↓</kbd> and <kbd>Enter</kbd>.'
        : 'Busca dentro de cada guía, sección y párrafo. Usa <kbd>↑</kbd> <kbd>↓</kbd> y <kbd>Enter</kbd>.';
    }


     function getNativeSearchUrl(query) {
     function getNativeSearchUrl(query) {
Línea 531: Línea 1371:
     }
     }


     function addResultItem(item) {
     function addResultItem(item, query) {
       var link = createElement('a', 'oroza-search-result-link');
       var link = createElement('a', 'oroza-search-result-link');
       link.href = item.href;
       link.href = item.href;
Línea 538: Línea 1378:


       var main = createElement('div', 'oroza-search-result-main');
       var main = createElement('div', 'oroza-search-result-main');
       main.appendChild(createElement('span', 'oroza-search-result-title', item.label || item.title));
 
       var title = createElement('span', 'oroza-search-result-title');
      appendHighlightedText(title, item.label || item.title, query);
      main.appendChild(title);
 
      if (item.location) {
        var location = createElement('span', 'oroza-search-result-location');
        appendHighlightedText(location, item.location, query);
        main.appendChild(location);
      }


       if (item.snippet) {
       if (item.snippet) {
         main.appendChild(createElement('p', 'oroza-search-result-snippet', item.snippet));
         var safeSnippet = cleanSearchCodeResidue(stripHtml(item.snippet));
        if (safeSnippet) {
          var snippet = createElement('p', 'oroza-search-result-snippet');
          appendHighlightedText(snippet, safeSnippet, query);
          main.appendChild(snippet);
        }
       }
       }


Línea 551: Línea 1405:
     function addSuggestion(suggestion) {
     function addSuggestion(suggestion) {
       if (!suggestion) return;
       if (!suggestion) return;
       var box = createElement('div', 'oroza-search-suggestion');
       var box = createElement('div', 'oroza-search-suggestion');
       box.appendChild(document.createTextNode(labels.suggestion + ': '));
       box.appendChild(document.createTextNode(labels.suggestion + ': '));
       var button = createElement('button', '', suggestion);
       var button = createElement('button', '', suggestion);
       button.type = 'button';
       button.type = 'button';
Línea 562: Línea 1414:
         input.focus();
         input.focus();
       });
       });
       box.appendChild(button);
       box.appendChild(button);
       results.appendChild(box);
       results.appendChild(box);
     }
     }


     function addFooter(query, totalHits) {
     function addFooter(query, totalHits, indexState) {
       var footer = createElement('div', 'oroza-search-footer');
       var footer = createElement('div', 'oroza-search-footer');
      var left = createElement('span', 'oroza-search-footer-count');
       var countText = '';
       var countText = '';


Línea 575: Línea 1427:
       }
       }


       footer.appendChild(createElement('span', '', countText));
       left.textContent = countText;
      footer.appendChild(left);
 
      if (indexState) {
        footer.appendChild(createElement('span', 'oroza-search-index-state', indexState));
      }


       var link = createElement('a', '', labels.viewAll + ' →');
       var link = createElement('a', '', labels.viewAll + ' →');
Línea 583: Línea 1440:
     }
     }


     function renderLoading(menuMatches) {
     function renderLoading(menuMatches, query) {
       while (results.firstChild) results.removeChild(results.firstChild);
       while (results.firstChild) results.removeChild(results.firstChild);
       if (menuMatches.length) {
       if (menuMatches.length) {
         addGroupTitle(labels.menuGroup);
         addGroupTitle(labels.menuGroup);
Línea 594: Línea 1450:
             href: item.href,
             href: item.href,
             category: item.category,
             category: item.category,
            location: labels.titleMatch,
             snippet: ''
             snippet: ''
           });
           }, query);
         });
         });
       }
       }
       var status = createElement('div', 'oroza-search-status');
       var status = createElement('div', 'oroza-search-status');
       status.appendChild(createElement('span', 'oroza-search-spinner'));
       status.appendChild(createElement('span', 'oroza-search-spinner'));
Línea 606: Línea 1462:
     }
     }


     function renderEmpty(query, menuMatches) {
     function renderState(query, state) {
       while (results.firstChild) results.removeChild(results.firstChild);
       while (results.firstChild) results.removeChild(results.firstChild);


       if (menuMatches.length) {
       var seen = Object.create(null);
        addGroupTitle(labels.menuGroup);
      var smartResults = [];
        menuMatches.forEach(function (item) {
      var apiResults = [];
          addResultItem({
       var menuMatches = [];
            title: item.title,
            label: item.label,
            href: item.href,
            category: item.category,
            snippet: ''
          });
        });
        addFooter(query, menuMatches.length);
       } else {
        var empty = createElement('div', 'oroza-search-empty');
        empty.appendChild(createElement('span', 'oroza-search-empty-icon', '🔍'));
        empty.appendChild(createElement('p', 'oroza-search-empty-title', labels.noResultsTitle));
        empty.appendChild(createElement('p', 'oroza-search-empty-text', labels.noResultsText));
        results.appendChild(empty);
        addFooter(query, 0);
      }


       setOpen(true);
       (state.smart || []).forEach(function (item) {
    }
        var key = normalizeText(item.title);
 
         if (!key || seen[key]) return;
    function renderApiError(query, menuMatches) {
        seen[key] = true;
      while (results.firstChild) results.removeChild(results.firstChild);
        smartResults.push(item);
 
       });
      if (menuMatches.length) {
        addGroupTitle(labels.menuGroup);
        menuMatches.forEach(function (item) {
          addResultItem({
            title: item.title,
            label: item.label,
            href: item.href,
            category: item.category,
            snippet: ''
          });
         });
      }
 
      var empty = createElement('div', 'oroza-search-empty');
      empty.appendChild(createElement('span', 'oroza-search-empty-icon', '⚠️'));
      empty.appendChild(createElement('p', 'oroza-search-empty-title', labels.apiErrorTitle));
      empty.appendChild(createElement('p', 'oroza-search-empty-text', labels.apiErrorText));
      results.appendChild(empty);
       addFooter(query);
      setOpen(true);
    }
 
    function renderResults(query, menuMatches, payload) {
      while (results.firstChild) results.removeChild(results.firstChild);


       var apiResults = payload && payload.items ? payload.items : [];
       (state.api || []).forEach(function (item) {
      var seen = Object.create(null);
      var visibleMenuMatches = [];
      var visibleApiResults = [];
 
      menuMatches.forEach(function (item) {
         var key = normalizeText(item.title);
         var key = normalizeText(item.title);
         if (!key || seen[key]) return;
         if (!key || seen[key]) return;
         seen[key] = true;
         seen[key] = true;
         visibleMenuMatches.push(item);
         apiResults.push(item);
       });
       });


       apiResults.forEach(function (item) {
       (state.menu || []).forEach(function (item) {
         var key = normalizeText(item.title);
         var key = normalizeText(item.title);
         if (!key || seen[key]) return;
         if (!key || seen[key]) return;
         seen[key] = true;
         seen[key] = true;
         visibleApiResults.push(item);
         menuMatches.push(item);
       });
       });


       if (!visibleMenuMatches.length && !visibleApiResults.length) {
       if (smartResults.length) {
         renderEmpty(query, []);
        addGroupTitle(labels.smartGroup);
         return;
        smartResults.forEach(function (item) { addResultItem(item, query); });
      }
 
      if (apiResults.length) {
         addGroupTitle(labels.apiGroup);
         apiResults.forEach(function (item) { addResultItem(item, query); });
       }
       }


       if (visibleMenuMatches.length) {
       if (menuMatches.length) {
         addGroupTitle(labels.menuGroup);
         addGroupTitle(labels.menuGroup);
         visibleMenuMatches.forEach(function (item) {
         menuMatches.forEach(function (item) {
           addResultItem({
           addResultItem({
             title: item.title,
             title: item.title,
Línea 693: Línea 1509:
             href: item.href,
             href: item.href,
             category: item.category,
             category: item.category,
            location: labels.titleMatch,
             snippet: ''
             snippet: ''
           });
           }, query);
         });
         });
       }
       }


       if (visibleApiResults.length) {
       if (!smartResults.length && !apiResults.length && !menuMatches.length) {
         addGroupTitle(labels.wikiGroup);
         var empty = createElement('div', 'oroza-search-empty');
         visibleApiResults.forEach(addResultItem);
        empty.appendChild(createElement('span', 'oroza-search-empty-icon', '🔍'));
        empty.appendChild(createElement('p', 'oroza-search-empty-title', labels.noResultsTitle));
        empty.appendChild(createElement('p', 'oroza-search-empty-text', labels.noResultsText));
         results.appendChild(empty);
       }
       }


       addSuggestion(payload.suggestion);
      if (state.smartLoading) {
       addFooter(query, payload.totalHits);
        var loading = createElement('div', 'oroza-search-smart-loading');
        loading.appendChild(createElement('span', 'oroza-search-spinner'));
        loading.appendChild(createElement('span', '', labels.smartLoading));
        results.appendChild(loading);
      }
 
      if (state.apiFailed && !apiResults.length) {
        var warning = createElement('div', 'oroza-search-inline-warning');
        warning.textContent = labels.apiErrorTitle + '. ' + labels.apiErrorText;
        results.appendChild(warning);
      }
 
       addSuggestion(state.suggestion);
 
      var total = typeof state.totalHits === 'number'
        ? Math.max(state.totalHits, smartResults.length)
        : smartResults.length + apiResults.length + menuMatches.length;
      var indexState = '';
      if (typeof state.indexCount === 'number') indexState = state.indexCount + ' ' + labels.indexedGuides;
      else if (state.smartFailed) indexState = labels.indexUnavailable;
 
       addFooter(query, total, indexState);
       setOpen(true);
       setOpen(true);
     }
     }
Línea 718: Línea 1559:
         items: items.map(function (item) {
         items: items.map(function (item) {
           var category = categoryMap[normalizeText(item.title)] || labels.categoryFallback;
           var category = categoryMap[normalizeText(item.title)] || labels.categoryFallback;
           var section = stripHtml(item.sectiontitle || '');
           var section = cleanSearchCodeResidue(stripHtml(item.sectiontitle || ''));
           var snippet = stripHtml(item.snippet || '');
           var snippet = cleanSearchCodeResidue(stripHtml(item.snippet || ''));
          var location = labels.insideGuide;


           if (section) {
           if (section) location += ' · ' + labels.sectionPrefix + ': ' + section;
            snippet = labels.sectionPrefix + ': ' + section + (snippet ? ' — ' + snippet : '');
          }


           return {
           return {
Línea 730: Línea 1570:
             href: (window.mw && mw.util) ? mw.util.getUrl(item.title) : '/wiki/index.php?title=' + encodeURIComponent(item.title.replace(/ /g, '_')),
             href: (window.mw && mw.util) ? mw.util.getUrl(item.title) : '/wiki/index.php?title=' + encodeURIComponent(item.title.replace(/ /g, '_')),
             category: category,
             category: category,
            location: location,
             snippet: snippet
             snippet: snippet
           };
           };
Línea 736: Línea 1577:
     }
     }


     function runSearch(rawQuery) {
     function searchNative(query) {
       var query = String(rawQuery || '').trim();
       var key = normalizeText(query);
       var menuMatches = findMenuMatches(menuEntries, query);
       if (apiCache[key]) return Promise.resolve(apiCache[key]);
      var normalizedQuery = normalizeText(query);
       if (!api) return Promise.reject(new Error('MediaWiki API unavailable'));
 
       if (query.length < MIN_QUERY_LENGTH) {
        clearResults();
        return;
      }


       renderLoading(menuMatches);
       return api.get({
 
      if (cache[normalizedQuery]) {
        renderResults(query, menuMatches, cache[normalizedQuery]);
        return;
      }
 
      if (!api) {
        renderApiError(query, menuMatches);
        return;
      }
 
      var thisRequest = ++requestSequence;
 
      api.get({
         action: 'query',
         action: 'query',
         list: 'search',
         list: 'search',
         srsearch: query,
         srsearch: query,
        srwhat: 'text',
         srnamespace: 0,
         srnamespace: 0,
         srlimit: API_LIMIT,
         srlimit: API_LIMIT,
Línea 771: Línea 1594:
         formatversion: 2,
         formatversion: 2,
         utf8: 1
         utf8: 1
       }).done(function (data) {
       }).then(function (data) {
         if (thisRequest !== requestSequence) return;
         var payload = transformApiData(data);
        apiCache[key] = payload;
        return payload;
      });
    }


         var payload = transformApiData(data);
    function runSearch(rawQuery) {
         cache[normalizedQuery] = payload;
      var query = String(rawQuery || '').trim();
         renderResults(query, menuMatches, payload);
      if (query.length < MIN_QUERY_LENGTH) {
       }).fail(function () {
        clearResults();
         if (thisRequest !== requestSequence) return;
         return;
         renderApiError(query, menuMatches);
      }
 
      var currentRequest = ++requestSequence;
      var state = {
        menu: findMenuMatches(menuEntries, query),
        smart: [],
         api: [],
        smartLoading: true,
        smartFailed: false,
        apiFailed: false,
        suggestion: '',
        totalHits: null,
        indexCount: null
      };
 
      renderLoading(state.menu, query);
 
      searchNative(query).then(function (payload) {
        if (currentRequest !== requestSequence) return;
        state.api = payload.items;
         state.suggestion = payload.suggestion;
        state.totalHits = payload.totalHits;
        renderState(query, state);
      }).catch(function () {
        if (currentRequest !== requestSequence) return;
        state.apiFailed = true;
        renderState(query, state);
      });
 
      ensureSmartIndex(api, categoryMap).then(function (index) {
        if (currentRequest !== requestSequence) return;
        state.smart = searchSmartIndex(index, query, labels, synonymMap);
        state.smartLoading = false;
        state.indexCount = index.length;
        renderState(query, state);
       }).catch(function () {
         if (currentRequest !== requestSequence) return;
         state.smartLoading = false;
        state.smartFailed = true;
        renderState(query, state);
       });
       });
     }
     }
Línea 790: Línea 1656:
       var links = getResultLinks();
       var links = getResultLinks();
       if (!links.length) return;
       if (!links.length) return;
      links.forEach(function (link) {
        link.classList.remove('is-active');
        link.setAttribute('aria-selected', 'false');
      });


       if (index < 0) index = links.length - 1;
       if (index < 0) index = links.length - 1;
       if (index >= links.length) index = 0;
       if (index >= links.length) index = 0;
      activeIndex = index;


       activeIndex = index;
       links.forEach(function (link, itemIndex) {
      links[activeIndex].classList.add('is-active');
        var active = itemIndex === activeIndex;
      links[activeIndex].setAttribute('aria-selected', 'true');
        link.classList.toggle('is-active', active);
      links[activeIndex].scrollIntoView({ block: 'nearest' });
        link.setAttribute('aria-selected', active ? 'true' : 'false');
        if (active) link.scrollIntoView({ block: 'nearest' });
      });
     }
     }


     input.addEventListener('input', function () {
     input.addEventListener('input', function () {
       var query = input.value;
       var query = input.value.trim();
       clearButton.hidden = !query;
       clearButton.hidden = !query;
       activeIndex = -1;
       activeIndex = -1;
       window.clearTimeout(debounceTimer);
       window.clearTimeout(debounceTimer);


       if (String(query || '').trim().length < MIN_QUERY_LENGTH) {
       if (query.length < MIN_QUERY_LENGTH) {
         requestSequence++;
         requestSequence++;
         clearResults();
         clearResults();
Línea 824: Línea 1687:


     input.addEventListener('focus', function () {
     input.addEventListener('focus', function () {
       if (input.value.trim().length >= MIN_QUERY_LENGTH && results.childNodes.length) {
       if (input.value.trim().length >= MIN_QUERY_LENGTH && results.hidden) {
         setOpen(true);
         runSearch(input.value);
       }
       }
     });
     });
Línea 872: Línea 1735:
   function boot(root) {
   function boot(root) {
     root = root || document;
     root = root || document;
     var widgets = root.querySelectorAll ? root.querySelectorAll(SELECTOR) : [];
     var widgets = root.querySelectorAll ? root.querySelectorAll(SELECTOR) : [];
     Array.prototype.forEach.call(widgets, function (widget) {
     Array.prototype.forEach.call(widgets, function (widget) {
Línea 879: Línea 1741:
   }
   }


   function bootWithMediaWikiModules(root) {
   function bootWithModules(root) {
     if (window.mw && mw.loader && mw.loader.using) {
     if (window.mw && mw.loader && mw.loader.using) {
       mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(function () {
       mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(function () {
Línea 890: Línea 1752:
     }
     }
   }
   }
  window.OrozaWikiSearch = {
    rebuild: function () {
      clearIndexCache();
      return true;
    },
    clearCache: clearIndexCache
  };


   if (document.readyState === 'loading') {
   if (document.readyState === 'loading') {
     document.addEventListener('DOMContentLoaded', function () {
     document.addEventListener('DOMContentLoaded', function () {
       bootWithMediaWikiModules(document);
       bootWithModules(document);
     });
     });
   } else {
   } else {
     bootWithMediaWikiModules(document);
     bootWithModules(document);
   }
   }


Línea 902: Línea 1772:
     mw.hook('wikipage.content').add(function ($content) {
     mw.hook('wikipage.content').add(function ($content) {
       var node = $content && $content[0] ? $content[0] : document;
       var node = $content && $content[0] ? $content[0] : document;
       bootWithMediaWikiModules(node);
       bootWithModules(node);
       bootWithMediaWikiModules(document);
       bootWithModules(document);
     });
     });
   }
   }
})();
})();

Revisión actual - 11:30 10 jul 2026

(function () {
  function safeNormalize(text) {
    text = String(text || '').toLowerCase().trim();

    try {
      return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
    } catch (e) {
      return text;
    }
  }

  function hasMark(cellText) {
    cellText = String(cellText || '').replace(/\s+/g, '').trim();

    return (
      cellText !== '' &&
      (
        cellText.indexOf('✔') !== -1 ||
        cellText.indexOf('✅') !== -1 ||
        cellText.indexOf('☑') !== -1
      )
    );
  }

  function getActiveCategoryCol(tableId) {
    var activeBtn = document.querySelector('.oroza-cat-btn.active[data-table="' + tableId + '"]');
    if (!activeBtn) {
      var firstBtn = document.querySelector('.oroza-cat-btn[data-table="' + tableId + '"]');
      if (!firstBtn) return null;
      firstBtn.classList.add('active');
      return firstBtn.getAttribute('data-col'); 
    }
    return activeBtn.getAttribute('data-col'); // Puede retornar "all" en string
  }

  function getSearchTextForRow(row, tableId) {
    var cells = row.getElementsByTagName('td');
    var text = row.getAttribute('data-search') || '';
    // Como añadimos "Prob.", ahora las columnas de información base son 3 en Slot 1 y 4 en Slot 2
    var visibleInfoCols = tableId === 'tableSlot1' ? 3 : 4;

    for (var i = 0; i < visibleInfoCols; i++) {
      if (cells[i]) {
        text += ' ' + (cells[i].textContent || cells[i].innerText || '');
      }
    }

    return safeNormalize(text);
  }

  function updateCategoryView(tableId, inputId) {
    var table = document.getElementById(tableId);
    var input = document.getElementById(inputId);

    if (!table || !table.tBodies.length || !table.tHead || !table.tHead.rows.length) return;

    var activeColRaw = getActiveCategoryCol(tableId);
    if (activeColRaw === null) return;

    var showAll = (activeColRaw === 'all');
    var activeCol = showAll ? -1 : parseInt(activeColRaw, 10);

    var searchText = input ? safeNormalize(input.value) : '';
    var rows = table.tBodies[0].getElementsByTagName('tr');
    var headerRow = table.tHead.rows[0];
    
    // Columnas que SIEMPRE se ven (Atributo, Prob, Rango, etc.)
    var staticCols = tableId === 'tableSlot1' ? 3 : 4;

    // Actualizar Header
    for (var h = 0; h < headerRow.cells.length; h++) {
      headerRow.cells[h].style.display = (showAll || h < staticCols || h === activeCol) ? '' : 'none';
    }

    // Actualizar Body
    for (var i = 0; i < rows.length; i++) {
      var row = rows[i];
      var cells = row.getElementsByTagName('td');
      
      // Si estamos en "Todos", empezamos asumiendo que sí tiene categoría válida
      var matchCategory = showAll;
      var matchText = true;

      // Si se escogió una categoría específica, verificamos si tiene "✔"
      if (!showAll && cells[activeCol]) {
        matchCategory = hasMark(cells[activeCol].textContent || cells[activeCol].innerText);
      }

      // Verificación de búsqueda por texto
      if (searchText !== '') {
        var searchPool = getSearchTextForRow(row, tableId);
        if (searchPool.indexOf(searchText) === -1) {
          matchText = false;
        }
      }

      row.style.display = (matchCategory && matchText) ? '' : 'none';

      for (var c = 0; c < cells.length; c++) {
        cells[c].style.display = (showAll || c < staticCols || c === activeCol) ? '' : 'none';
      }
    }
  }

  function bindCategoryButtons(tableId, inputId) {
    var buttons = document.querySelectorAll('.oroza-cat-btn[data-table="' + tableId + '"]');

    for (var i = 0; i < buttons.length; i++) {
      if (buttons[i].dataset.orozaBound === '1') continue;

      buttons[i].dataset.orozaBound = '1';
      buttons[i].addEventListener('click', function () {
        var sameTableButtons = document.querySelectorAll('.oroza-cat-btn[data-table="' + tableId + '"]');
        for (var j = 0; j < sameTableButtons.length; j++) {
          sameTableButtons[j].classList.remove('active');
        }

        this.classList.add('active');
        updateCategoryView(tableId, inputId);
      });
    }
  }

  function bindSearchInput(tableId, inputId) {
    var input = document.getElementById(inputId);
    if (!input || input.dataset.orozaBound === '1') return;

    input.dataset.orozaBound = '1';
    input.addEventListener('input', function () {
      updateCategoryView(tableId, inputId);
    });
  }

  function initOrozaDropFilters() {
    if (document.getElementById('tableSlot1')) {
      bindCategoryButtons('tableSlot1', 'textSlot1');
      bindSearchInput('tableSlot1', 'textSlot1');
      updateCategoryView('tableSlot1', 'textSlot1');
    }

    if (document.getElementById('tableSlot2')) {
      bindCategoryButtons('tableSlot2', 'textSlot2');
      bindSearchInput('tableSlot2', 'textSlot2');
      updateCategoryView('tableSlot2', 'textSlot2');
    }
  }

  if (window.mw && mw.hook) {
    mw.hook('wikipage.content').add(function () {
      initOrozaDropFilters();
    });
  } else if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initOrozaDropFilters);
  } else {
    initOrozaDropFilters();
  }
})();

/* =========================================================
   RESALTAR MENÚ LATERAL ACTIVO (VERSIÓN NATIVA MEDIAWIKI)
========================================================= */
function highlightActiveSidebarLink() {
  // 1. Obtenemos el nombre exacto de la página actual desde el motor de MediaWiki
  var currentPage = mw.config.get('wgPageName');
  if (!currentPage) return;

  // Limpiamos el nombre de la página actual (todo a minúsculas y espacios a guiones bajos)
  currentPage = currentPage.replace(/ /g, '_').toLowerCase();

  var sidebarLinks = document.querySelectorAll('.oroza-sidebar li a');

  for (var i = 0; i < sidebarLinks.length; i++) {
    var linkHref = sidebarLinks[i].getAttribute('href');
    if (!linkHref) continue;

    // 2. Extraemos el nombre de la página del enlace del menú
    var linkPage = "";
    if (linkHref.indexOf('title=') !== -1) {
      linkPage = linkHref.split('title=')[1].split('&')[0];
    } else {
      linkPage = linkHref.split('/').pop(); // Toma lo último después del slash (ej. "Como-instalarlo")
    }

    if (!linkPage) continue;

    // 3. Decodificamos caracteres especiales (como acentos o espacios %20)
    try {
      linkPage = decodeURIComponent(linkPage);
    } catch(e) {}

    // Limpiamos el enlace de la misma forma para que la comparación sea exacta
    linkPage = linkPage.replace(/ /g, '_').toLowerCase();

    // 4. Comparamos
    if (currentPage === linkPage) {
      sidebarLinks[i].classList.add('active');
    } else {
      sidebarLinks[i].classList.remove('active'); // Limpia los demás por si acaso
    }
  }
}

// Integración con MediaWiki
if (window.mw && mw.hook) {
  mw.hook('wikipage.content').add(highlightActiveSidebarLink);
} else if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', highlightActiveSidebarLink);
} else {
  highlightActiveSidebarLink();
}



/* =========================================================
   OROZA PET ATTACK REBALANCE V13.3 - SIMPLE SEARCH
   Paste into MediaWiki:Common.js
========================================================= */
(function () {
  function normalize(text) {
    return (text || '')
      .toString()
      .toLowerCase()
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .replace(/_/g, ' ')
      .replace(/\.{2,}/g, '…')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function initOrozaPetSearchV9(root) {
    root = root || document;

    var input = root.querySelector ? root.querySelector('#orozaPetSearchV6') : document.getElementById('orozaPetSearchV6');
    if (!input || input.dataset.ready === 'v9') return;
    input.dataset.ready = 'v9';

    var container = input.closest('.oroza-pet-v6') || document;
    var cards = Array.prototype.slice.call(container.querySelectorAll('.oroza-pet-v6-card'));
    var sections = Array.prototype.slice.call(container.querySelectorAll('[data-pet-section]'));
    var result = container.querySelector('#orozaPetSearchV6Result');
    var clearBtn = container.querySelector('[data-pet-clear]');

    function applySearch() {
      var q = normalize(input.value);
      var shown = 0;

      cards.forEach(function (card) {
        var haystack = normalize((card.getAttribute('data-pet-search') || '') + ' ' + card.textContent);
        var match = !q || haystack.indexOf(q) !== -1;

        card.classList.toggle('is-hidden', !match);
        if (match) shown++;
      });

      sections.forEach(function (section) {
        var visibleCards = section.querySelectorAll('.oroza-pet-v6-card:not(.is-hidden)').length;
        var shouldHide = visibleCards === 0 && q;
        section.classList.toggle('is-hidden', shouldHide);
        if (!shouldHide && q) section.open = true;
      });

      if (result) {
        if (shown === 0) {
          result.textContent = 'No pets were found with that text.';
        } else if (q) {
          result.textContent = 'Visible results: ' + shown + '. Open the card to review each Pokémon skill and level.';
        } else {
          result.textContent = 'Showing all pets.';
        }
      }
    }

    input.addEventListener('input', applySearch);

    if (clearBtn) {
      clearBtn.addEventListener('click', function () {
        input.value = '';
        applySearch();
        input.focus();
      });
    }

    applySearch();
  }

  function boot() {
    initOrozaPetSearchV9(document);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }

  if (window.mw && window.mw.hook) {
    window.mw.hook('wikipage.content').add(function ($content) {
      var node = $content && $content[0] ? $content[0] : document;
      initOrozaPetSearchV9(node);
    });
  }
})();

/* =========================================================
   OROZA RO SMART WIKI SEARCH V2.2
   ---------------------------------------------------------
   - Searches inside the complete text of every main Wiki page
   - Detects headings/sections and shows the matching fragment
   - Combines a local smart index with MediaWiki native search
   - Adds aliases/synonyms for common Ragnarok Online terms
   - Uses cache to avoid downloading the Wiki on every search
   - Keyboard navigation, mobile support and native fallback
========================================================= */
(function () {
  'use strict';

  var SELECTOR = '[data-oroza-global-search]';
  var MIN_QUERY_LENGTH = 2;
  var SMART_LIMIT = 10;
  var API_LIMIT = 8;
  var MENU_LIMIT = 3;
  var DEBOUNCE_MS = 240;
  var INDEX_TTL_MS = 2 * 60 * 60 * 1000;
  var MAX_INDEX_PAGES = 400;
  var MAX_PAGE_TEXT = 60000;
  var CACHE_VERSION = 4;

  var indexPromise = null;
  var memoryIndex = null;

  function normalizeText(value) {
    var text = String(value || '')
      .toLowerCase()
      .replace(/_/g, ' ')
      .replace(/&nbsp;/gi, ' ')
      .replace(/[\u2010-\u2015]/g, '-')
      .replace(/[^a-z0-9@+#áéíóúüñçàèìòùâêîôûäëïöü\s-]/gi, ' ')
      .replace(/\s+/g, ' ')
      .trim();

    try {
      text = text.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
    } catch (e) {}

    return text;
  }

  function decodeHtmlEntities(value) {
    var text = String(value || '');

    /* MediaWiki search snippets can arrive HTML-escaped more than once.
       Decode repeatedly so &lt;h2&gt; becomes a real tag before removing it. */
    for (var i = 0; i < 3; i++) {
      var decoder = document.createElement('textarea');
      decoder.innerHTML = text;
      var decoded = decoder.value;

      if (decoded === text) break;
      text = decoded;
    }

    return text;
  }

  function cleanSearchCodeResidue(value) {
    var text = String(value || '');

    /* Native MediaWiki snippets can begin or end in the middle of an HTML
       attribute. In that case there is no complete tag for the browser to
       remove, so fragments such as object-fit, drop-shadow or rgba survive.
       This pass removes only presentation/code residue, preserving guide text. */
    var cssProperties = [
      'object-fit', 'bject-fit', 'ject-fit', 'object-position',
      'filter', 'box-shadow', 'text-shadow', 'background', 'background-color',
      'background-image', 'border', 'border-radius', 'border-color',
      'border-width', 'border-style', 'color', 'display', 'position',
      'top', 'right', 'bottom', 'left', 'width', 'height', 'max-width',
      'min-width', 'max-height', 'min-height', 'margin', 'margin-top',
      'margin-right', 'margin-bottom', 'margin-left', 'padding', 'padding-top',
      'padding-right', 'padding-bottom', 'padding-left', 'font-size',
      'font-weight', 'font-family', 'font-style', 'line-height',
      'letter-spacing', 'text-align', 'text-decoration', 'white-space',
      'vertical-align', 'overflow', 'overflow-x', 'overflow-y', 'opacity',
      'transform', 'transform-origin', 'transition', 'animation', 'cursor',
      'z-index', 'float', 'clear', 'content', 'visibility', 'flex',
      'flex-grow', 'flex-shrink', 'flex-basis', 'flex-direction', 'flex-wrap',
      'align-items', 'align-content', 'align-self', 'justify-content',
      'justify-items', 'justify-self', 'gap', 'row-gap', 'column-gap',
      'grid', 'grid-template-columns', 'grid-template-rows', 'grid-column',
      'grid-row', 'list-style', 'list-style-type', 'object-fit'
    ];

    var propertyPattern = new RegExp(
      '(?:^|[\\s"\\\';])(?:' + cssProperties.join('|') + ')\\s*:\\s*[^;{}<>]{0,600};',
      'gi'
    );

    for (var i = 0; i < 4; i++) {
      var before = text;
      text = text.replace(propertyPattern, ' ');
      if (text === before) break;
    }

    /* Remove truncated generic declarations only when they clearly contain a
       CSS value/function. This avoids deleting normal phrases such as
       "Reward: 10 Zeny". */
    var cssValuePattern = /(?:^|[\s"';,.…])[-a-z]{2,}\s*:\s*[^;{}<>]{0,500}(?:rgba?\s*\(|hsla?\s*\(|drop-shadow\s*\(|linear-gradient\s*\(|radial-gradient\s*\(|url\s*\(|calc\s*\(|var\s*\(|\b(?:contain|cover|flex|grid|block|inline-block|absolute|relative|fixed|sticky|hidden|visible|solid|dashed|none|auto|center)\b|\d+(?:\.\d+)?(?:px|rem|em|vh|vw|%))[^;{}<>]*;?/gi;

    for (var j = 0; j < 3; j++) {
      var previous = text;
      text = text.replace(cssValuePattern, ' ');
      if (text === previous) break;
    }

    text = text
      .replace(/\b(?:class|style|id|src|srcset|alt|title|width|height|href|target|rel|loading|decoding|data-[a-z0-9_-]+|aria-[a-z0-9_-]+)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ' ')
      .replace(/\b(?:drop-shadow|rgba?|hsla?|linear-gradient|radial-gradient|translate(?:x|y|3d)?|scale(?:x|y|3d)?|rotate(?:x|y|z|3d)?|calc|var|url)\s*\([^;{}<>]{0,500}\)/gi, ' ')
      .replace(/(?:^|\s)(?:px|rem|em|vh|vw)\b/gi, ' ')
      .replace(/\s*(?:\/?>|<\/?)\s*/g, ' ')
      .replace(/["'`{}]+/g, ' ')
      .replace(/\.{2,}/g, '…')
      .replace(/\s+/g, ' ')
      .trim();

    return text;
  }

  function stripHtml(value) {
    var text = decodeHtmlEntities(value)
      .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
      .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
      .replace(/<!--([\s\S]*?)-->/g, ' ');

    /* Parse the decoded HTML in a detached element. This removes tags such
       as h2, img, span, table, code and their attributes without executing it. */
    for (var i = 0; i < 2; i++) {
      var node = document.createElement('div');
      node.innerHTML = text;
      var plain = node.textContent || node.innerText || '';

      if (plain === text) break;
      text = plain;
    }

    /* Final defensive cleanup for malformed or partially escaped markup. */
    text = decodeHtmlEntities(text)
      .replace(/<[^>]*>/g, ' ')
      .replace(/\b(?:class|style|id|src|alt|title|width|height|href|target|rel|loading)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ' ')
      .replace(/(?:^|\s)(?:div|span|table|tbody|thead|tr|td|th|img|h[1-6]|p|br|code|strong|em)(?:\s|$)/gi, ' ');

    return cleanSearchCodeResidue(cleanWikiText(text))
      .replace(/\s+/g, ' ')
      .trim();
  }

  function cleanWikiText(value) {
    var text = decodeHtmlEntities(value);

    text = text
      .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
      .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
      .replace(/<ref\b[^>]*>[\s\S]*?<\/ref>/gi, ' ')
      .replace(/<ref\b[^>]*\/\s*>/gi, ' ')
      .replace(/<!--([\s\S]*?)-->/g, ' ')
      .replace(/\{\|[\s\S]*?\|\}/g, function (table) {
        return table.replace(/[|!{}]/g, ' ');
      })
      .replace(/\[\[Category:[^\]]+\]\]/gi, ' ')
      .replace(/\[\[File:[^\]]+\]\]/gi, ' ')
      .replace(/\[\[Image:[^\]]+\]\]/gi, ' ')
      .replace(/\[\[[^\]|]+\|([^\]]+)\]\]/g, '$1')
      .replace(/\[\[([^\]]+)\]\]/g, '$1')
      .replace(/\[(?:https?:)?\/\/[^\s\]]+\s+([^\]]+)\]/g, '$1')
      .replace(/\[(?:https?:)?\/\/[^\]]+\]/g, ' ')
      .replace(/\{\{[^{}]*\}\}/g, ' ')
      .replace(/\{\{[^{}]*\}\}/g, ' ')
      .replace(/<[^>]+>/g, ' ')
      .replace(/'{2,5}/g, '')
      .replace(/={2,6}/g, ' ')
      .replace(/__[^_]+__/g, ' ')
      .replace(/[|{}]/g, ' ')
      .replace(/\s+/g, ' ')
      .trim();

    return cleanSearchCodeResidue(text);
  }

  function extractKeywords(wikitext) {
    var keywords = [];
    var pattern = /<!--\s*OROZA-KEYWORDS\s*:\s*([\s\S]*?)-->/gi;
    var match;

    while ((match = pattern.exec(String(wikitext || '')))) {
      match[1].split(/[,;|\n]/).forEach(function (item) {
        item = item.trim();
        if (item) keywords.push(item);
      });
    }

    return keywords;
  }

  function extractCategories(wikitext) {
    var categories = [];
    var pattern = /\[\[Category\s*:\s*([^\]|]+)(?:\|[^\]]*)?\]\]/gi;
    var match;

    while ((match = pattern.exec(String(wikitext || '')))) {
      var category = cleanWikiText(match[1]);
      if (category && categories.indexOf(category) === -1) categories.push(category);
    }

    return categories;
  }

  function splitIntoSections(wikitext) {
    var source = decodeHtmlEntities(wikitext);

    /* Many Oroza Wiki guides are built with literal HTML instead of == Wiki
       headings ==. Convert HTML headings and accordion summaries into virtual
       Wiki headings so the search can identify the exact section. */
    source = source.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, function (match, level, title) {
      var headingLevel = Math.max(2, Math.min(6, parseInt(level, 10) || 2));
      var marks = '======'.slice(0, headingLevel);
      var cleanTitle = stripHtml(title);
      return cleanTitle ? '\n' + marks + ' ' + cleanTitle + ' ' + marks + '\n' : '\n';
    });

    source = source.replace(/<summary\b[^>]*>([\s\S]*?)<\/summary>/gi, function (match, title) {
      var cleanTitle = stripHtml(title);
      return cleanTitle ? '\n=== ' + cleanTitle + ' ===\n' : '\n';
    });

    var lines = source.split(/\r?\n/);
    var sections = [];
    var currentTitle = '';
    var currentLines = [];

    function flush() {
      var plain = cleanWikiText(currentLines.join('\n'));
      if (plain) {
        sections.push({
          title: cleanWikiText(currentTitle),
          text: plain.slice(0, MAX_PAGE_TEXT)
        });
      }
      currentLines = [];
    }

    lines.forEach(function (line) {
      var heading = line.match(/^\s*(={2,6})\s*(.*?)\s*\1\s*$/);
      if (heading) {
        flush();
        currentTitle = stripHtml(heading[2]);
      } else {
        currentLines.push(line);
      }
    });

    flush();

    if (!sections.length) {
      var plain = cleanWikiText(source);
      if (plain) sections.push({ title: '', text: plain.slice(0, MAX_PAGE_TEXT) });
    }

    return sections;
  }

  function decodePageName(href) {
    try {
      var url = new URL(href, window.location.href);
      var title = url.searchParams.get('title');

      if (!title) {
        var marker = '/wiki/index.php/';
        var markerIndex = url.pathname.indexOf(marker);
        if (markerIndex !== -1) title = url.pathname.slice(markerIndex + marker.length);
      }

      if (!title) return '';
      return decodeURIComponent(title).replace(/_/g, ' ').trim();
    } catch (e) {
      return '';
    }
  }

  function getLocale() {
    var isEnglish = window.location.hostname === 'en.orozaro.com';

    return isEnglish ? {
      placeholder: 'Search guides, commands, items, systems and content...',
      ariaLabel: 'Search inside every Oroza RO Wiki guide',
      searchButton: 'Search',
      loading: 'Searching titles, sections and guide content...',
      smartLoading: 'Building the smart guide index for the first search...',
      smartGroup: 'Matches found inside guides',
      apiGroup: 'Other Wiki results',
      menuGroup: 'Quick links',
      noResultsTitle: 'No results found',
      noResultsText: 'Try another spelling, a shorter phrase, or a related Ragnarok term.',
      categoryFallback: 'Wiki guide',
      sectionPrefix: 'Section',
      insideGuide: 'Inside guide',
      titleMatch: 'Guide title',
      aliasMatch: 'Related term',
      resultSingular: 'result',
      resultPlural: 'results',
      viewAll: 'View native search',
      suggestion: 'Did you mean',
      indexedGuides: 'guides indexed',
      indexUnavailable: 'Smart index unavailable; showing native results.',
      apiErrorTitle: 'Native search is temporarily unavailable',
      apiErrorText: 'Smart guide matches are still available. Press Enter to open MediaWiki search.',
      introduction: 'Introduction'
    } : {
      placeholder: 'Buscar guías, comandos, objetos, sistemas y contenido...',
      ariaLabel: 'Buscar dentro de todas las guías de la Wiki de Oroza RO',
      searchButton: 'Buscar',
      loading: 'Buscando en títulos, secciones y contenido de las guías...',
      smartLoading: 'Creando el índice inteligente de las guías por primera vez...',
      smartGroup: 'Coincidencias encontradas dentro de las guías',
      apiGroup: 'Otros resultados de la Wiki',
      menuGroup: 'Accesos rápidos',
      noResultsTitle: 'No encontramos resultados',
      noResultsText: 'Prueba otra escritura, una frase más corta o un término relacionado de Ragnarok.',
      categoryFallback: 'Guía de la Wiki',
      sectionPrefix: 'Sección',
      insideGuide: 'Dentro de la guía',
      titleMatch: 'Título de la guía',
      aliasMatch: 'Término relacionado',
      resultSingular: 'resultado',
      resultPlural: 'resultados',
      viewAll: 'Ver búsqueda nativa',
      suggestion: 'Quizá quisiste decir',
      indexedGuides: 'guías indexadas',
      indexUnavailable: 'Índice inteligente no disponible; se muestran resultados nativos.',
      apiErrorTitle: 'La búsqueda nativa no está disponible temporalmente',
      apiErrorText: 'Las coincidencias del índice inteligente siguen disponibles. Presiona Enter para abrir la búsqueda de MediaWiki.',
      introduction: 'Introducción'
    };
  }

  function getSynonymMap(isEnglish) {
    var shared = {
      '@aa': ['auto attack', 'auto ataque', 'autoatk', 'automatic attack'],
      'aa': ['auto attack', 'auto ataque', 'autoatk'],
      'bg': ['battleground', 'battlegrounds', 'campo de batalla'],
      'woe': ['war of emperium', 'guild war', 'guerra de emperium'],
      'mvp': ['boss', 'jefe', 'monster boss'],
      'npc': ['non player character', 'personaje no jugador'],
      'exp': ['experience', 'experiencia'],
      'zeny': ['money', 'currency', 'dinero', 'moneda']
    };

    var english = {
      'beginner': ['starter', 'new player', 'newbie', 'first steps', 'getting started'],
      'newbie': ['beginner', 'starter', 'new player', 'first steps'],
      'starter': ['beginner', 'new player', 'newbie', 'first steps', '@starter'],
      'job': ['class', 'profession', 'job change'],
      'class': ['job', 'profession', 'job change'],
      'card': ['cards', 'monster card'],
      'cards': ['card', 'monster cards'],
      'hat': ['headgear', 'head gear', 'costume'],
      'headgear': ['hat', 'head gear', 'costume'],
      'refine': ['refinement', 'upgrade', 'safe refine'],
      'refinement': ['refine', 'upgrade', 'safe refine'],
      'enchant': ['enchantment', 'bonus', 'random option'],
      'quest': ['mission', 'task'],
      'mission': ['quest', 'task'],
      'party': ['group', 'even share'],
      'group': ['party', 'even share'],
      'drop': ['loot', 'reward', 'item drop'],
      'loot': ['drop', 'reward'],
      'pet': ['pokemon', 'companion', 'homunculus'],
      'pokemon': ['pet', 'companion'],
      'command': ['commands', '@command', 'chat command'],
      'donation': ['donate', 'cash points', 'support server'],
      'event': ['events', 'activity'],
      'skill': ['skills', 'ability'],
      'equipment': ['gear', 'weapon', 'armor'],
      'gear': ['equipment', 'weapon', 'armor']
    };

    var spanish = {
      'principiante': ['starter', 'jugador nuevo', 'novato', 'primeros pasos', 'inicio'],
      'novato': ['principiante', 'starter', 'jugador nuevo', 'primeros pasos'],
      'nuevo': ['principiante', 'starter', 'jugador nuevo', 'primeros pasos'],
      'starter': ['principiante', 'jugador nuevo', 'novato', 'primeros pasos', '@starter'],
      'job': ['clase', 'profesión', 'cambio de job'],
      'clase': ['job', 'profesión', 'cambio de clase'],
      'carta': ['cartas', 'card', 'monster card'],
      'cartas': ['carta', 'cards', 'monster cards'],
      'hat': ['sombrero', 'headgear', 'costume'],
      'sombrero': ['hat', 'headgear', 'costume'],
      'refinar': ['refinamiento', 'mejorar equipo', 'refine'],
      'refinamiento': ['refinar', 'mejorar equipo', 'safe refine'],
      'encantar': ['encantamiento', 'bonus', 'opción aleatoria'],
      'encantamiento': ['encantar', 'bonus', 'random option'],
      'quest': ['misión', 'mision', 'tarea'],
      'misión': ['quest', 'mision', 'tarea'],
      'mision': ['quest', 'misión', 'tarea'],
      'party': ['grupo', 'even share', 'compartir experiencia'],
      'grupo': ['party', 'even share', 'compartir experiencia'],
      'drop': ['loot', 'recompensa', 'objeto'],
      'loot': ['drop', 'recompensa'],
      'mascota': ['pokemon', 'pet', 'homúnculo', 'homunculo'],
      'pokemon': ['mascota', 'pet', 'compañero'],
      'comando': ['comandos', '@comando', 'chat command'],
      'donación': ['donar', 'cash points', 'apoyar servidor'],
      'donacion': ['donar', 'cash points', 'apoyar servidor'],
      'evento': ['eventos', 'actividad'],
      'skill': ['skills', 'habilidad'],
      'equipo': ['gear', 'arma', 'armadura']
    };

    var result = {};
    Object.keys(shared).forEach(function (key) { result[normalizeText(key)] = shared[key]; });
    Object.keys(isEnglish ? english : spanish).forEach(function (key) {
      result[normalizeText(key)] = (isEnglish ? english : spanish)[key];
    });
    return result;
  }

  function tokenize(value) {
    var stopWords = {
      'the': 1, 'a': 1, 'an': 1, 'and': 1, 'or': 1, 'of': 1, 'to': 1, 'in': 1, 'for': 1,
      'el': 1, 'la': 1, 'los': 1, 'las': 1, 'un': 1, 'una': 1, 'y': 1, 'o': 1, 'de': 1,
      'del': 1, 'en': 1, 'para': 1, 'como': 1, 'how': 1, 'what': 1, 'que': 1
    };

    return normalizeText(value).split(' ').filter(function (token) {
      return token && (token.length >= 2 || token.charAt(0) === '@') && !stopWords[token];
    });
  }

  function unique(values) {
    var seen = Object.create(null);
    return values.filter(function (value) {
      var key = normalizeText(value);
      if (!key || seen[key]) return false;
      seen[key] = true;
      return true;
    });
  }

  function expandQuery(query, synonymMap) {
    var originalTokens = tokenize(query);
    var expandedPhrases = [];

    originalTokens.forEach(function (token) {
      var synonyms = synonymMap[token] || [];
      synonyms.forEach(function (synonym) {
        expandedPhrases.push(synonym);
      });
    });

    return {
      phrase: normalizeText(query),
      originalTokens: unique(originalTokens),
      expandedTokens: unique(expandedPhrases.reduce(function (all, phrase) {
        return all.concat(tokenize(phrase));
      }, [])),
      expandedPhrases: unique(expandedPhrases.map(normalizeText))
    };
  }

  function collectMenuEntries() {
    var entries = [];
    var titles = document.querySelectorAll('.oroza-sidebar .menu-title');

    Array.prototype.forEach.call(titles, function (titleNode) {
      var category = (titleNode.textContent || '').replace(/\s+/g, ' ').trim();
      var list = titleNode.nextElementSibling;
      if (!list || String(list.tagName).toLowerCase() !== 'ul') return;

      Array.prototype.forEach.call(list.querySelectorAll('a[href]'), function (link) {
        var title = decodePageName(link.getAttribute('href'));
        var labelNode = link.querySelector('.menu-text');
        var label = labelNode ? labelNode.textContent : link.textContent;

        label = String(label || '').replace(/›/g, '').replace(/\s+/g, ' ').trim();
        title = title || label;
        if (!title || !link.href) return;

        entries.push({
          title: title,
          label: label || title,
          category: category,
          href: link.href,
          normalized: normalizeText(title + ' ' + label + ' ' + category)
        });
      });
    });

    return entries;
  }

  function findMenuMatches(entries, query) {
    var normalizedQuery = normalizeText(query);
    if (!normalizedQuery) return [];

    return entries.map(function (entry) {
      var normalizedTitle = normalizeText(entry.title + ' ' + entry.label);
      var score = 0;
      if (normalizedTitle === normalizedQuery) score = 100;
      else if (normalizedTitle.indexOf(normalizedQuery) === 0) score = 80;
      else if (normalizedTitle.indexOf(normalizedQuery) !== -1) score = 60;
      else if (entry.normalized.indexOf(normalizedQuery) !== -1) score = 40;
      return { entry: entry, score: score };
    }).filter(function (item) {
      return item.score > 0;
    }).sort(function (a, b) {
      return b.score - a.score || a.entry.label.localeCompare(b.entry.label);
    }).slice(0, MENU_LIMIT).map(function (item) {
      return item.entry;
    });
  }

  function buildCategoryMap(entries) {
    var map = Object.create(null);
    entries.forEach(function (entry) {
      map[normalizeText(entry.title)] = entry.category;
      map[normalizeText(entry.label)] = entry.category;
    });
    return map;
  }

  function getCacheKey() {
    return 'oroza-smart-wiki-index-v' + CACHE_VERSION + ':' + window.location.hostname;
  }

  function readIndexCache() {
    try {
      var raw = window.localStorage.getItem(getCacheKey());
      if (!raw) return null;
      var payload = JSON.parse(raw);
      if (!payload || !Array.isArray(payload.pages) || !payload.createdAt) return null;
      if (Date.now() - payload.createdAt > INDEX_TTL_MS) return null;
      return payload.pages;
    } catch (e) {
      return null;
    }
  }

  function writeIndexCache(pages) {
    try {
      window.localStorage.setItem(getCacheKey(), JSON.stringify({
        createdAt: Date.now(),
        pages: pages
      }));
    } catch (e) {}
  }

  function clearIndexCache() {
    try { window.localStorage.removeItem(getCacheKey()); } catch (e) {}
    memoryIndex = null;
    indexPromise = null;
  }

  function getRevisionContent(page) {
    var revision = page && page.revisions && page.revisions[0];
    if (!revision) return '';
    if (revision.slots && revision.slots.main) {
      return revision.slots.main.content || revision.slots.main['*'] || '';
    }
    return revision.content || revision['*'] || '';
  }

  function createIndexedPage(page, categoryMap) {
    var wikitext = getRevisionContent(page);
    var title = page.title || '';
    var sections = splitIntoSections(wikitext);
    var keywords = extractKeywords(wikitext);
    var categories = extractCategories(wikitext);
    var category = categoryMap[normalizeText(title)] || categories[0] || '';

    return {
      title: title,
      normalizedTitle: normalizeText(title),
      url: (window.mw && mw.util) ? mw.util.getUrl(title) : '/wiki/index.php?title=' + encodeURIComponent(title.replace(/ /g, '_')),
      category: category,
      keywords: keywords,
      normalizedKeywords: normalizeText(keywords.join(' ')),
      sections: sections.map(function (section) {
        return {
          title: section.title,
          normalizedTitle: normalizeText(section.title),
          text: section.text,
          normalizedText: normalizeText(section.text)
        };
      })
    };
  }

  function buildSmartIndex(api, categoryMap) {
    var pages = [];
    var continueParams = {};

    function fetchBatch() {
      var params = {
        action: 'query',
        generator: 'allpages',
        gapnamespace: 0,
        gapfilterredir: 'nonredirects',
        gaplimit: 'max',
        prop: 'revisions',
        rvprop: 'content',
        rvslots: 'main',
        rvlimit: 1,
        formatversion: 2,
        utf8: 1
      };

      Object.keys(continueParams).forEach(function (key) {
        params[key] = continueParams[key];
      });

      return api.get(params).then(function (data) {
        var batch = data && data.query && Array.isArray(data.query.pages) ? data.query.pages : [];

        batch.forEach(function (page) {
          if (pages.length >= MAX_INDEX_PAGES || page.missing) return;
          pages.push(createIndexedPage(page, categoryMap));
        });

        if (data && data.continue && pages.length < MAX_INDEX_PAGES) {
          continueParams = data.continue;
          return fetchBatch();
        }

        writeIndexCache(pages);
        memoryIndex = pages;
        return pages;
      });
    }

    return fetchBatch();
  }

  function ensureSmartIndex(api, categoryMap) {
    if (memoryIndex) return Promise.resolve(memoryIndex);

    var cached = readIndexCache();
    if (cached) {
      memoryIndex = cached;
      return Promise.resolve(cached);
    }

    if (!api) return Promise.reject(new Error('MediaWiki API unavailable'));
    if (!indexPromise) {
      indexPromise = buildSmartIndex(api, categoryMap).catch(function (error) {
        indexPromise = null;
        throw error;
      });
    }
    return indexPromise;
  }

  function countOccurrences(text, token) {
    if (!token) return 0;
    var count = 0;
    var position = 0;
    while ((position = text.indexOf(token, position)) !== -1) {
      count++;
      position += Math.max(token.length, 1);
      if (count >= 8) break;
    }
    return count;
  }

  function makeSnippet(text, queryData, maxLength) {
    var plain = cleanSearchCodeResidue(stripHtml(text)).replace(/\s+/g, ' ').trim();
    if (!plain) return '';

    var lower = plain.toLowerCase();
    var candidates = [String(queryData.raw || '').toLowerCase()]
      .concat(queryData.originalTokens || [])
      .concat(queryData.expandedTokens || []);
    var position = -1;

    candidates.some(function (candidate) {
      if (!candidate) return false;
      position = lower.indexOf(candidate.toLowerCase());
      return position !== -1;
    });

    if (position < 0) position = 0;
    var radius = Math.floor((maxLength || 220) / 2);
    var start = Math.max(0, position - radius);
    var end = Math.min(plain.length, start + (maxLength || 220));

    if (end - start < (maxLength || 220) && start > 0) {
      start = Math.max(0, end - (maxLength || 220));
    }

    var snippet = cleanSearchCodeResidue(plain.slice(start, end)).trim();
    if (!snippet) return '';
    if (start > 0) snippet = '…' + snippet;
    if (end < plain.length) snippet += '…';
    return snippet;
  }

  function scoreSection(page, section, queryData) {
    var title = page.normalizedTitle;
    var sectionTitle = section.normalizedTitle;
    var text = section.normalizedText;
    var keywords = page.normalizedKeywords;
    var phrase = queryData.phrase;
    var originalTokens = queryData.originalTokens;
    var expandedTokens = queryData.expandedTokens;
    var score = 0;
    var matchType = '';

    if (title === phrase) {
      score += 320;
      matchType = 'title';
    } else if (title.indexOf(phrase) === 0) {
      score += 260;
      matchType = 'title';
    } else if (title.indexOf(phrase) !== -1) {
      score += 220;
      matchType = 'title';
    }

    if (keywords && phrase && keywords.indexOf(phrase) !== -1) {
      score += 190;
      if (!matchType) matchType = 'alias';
    }

    if (sectionTitle === phrase) {
      score += 185;
      if (!matchType) matchType = 'section';
    } else if (sectionTitle && sectionTitle.indexOf(phrase) !== -1) {
      score += 160;
      if (!matchType) matchType = 'section';
    }

    if (phrase && text.indexOf(phrase) !== -1) {
      score += 135;
      if (!matchType) matchType = 'content';
    }

    var originalMatches = 0;
    var expandedMatches = 0;
    var frequency = 0;

    originalTokens.forEach(function (token) {
      var inTitle = title.indexOf(token) !== -1;
      var inSection = sectionTitle.indexOf(token) !== -1;
      var inKeywords = keywords.indexOf(token) !== -1;
      var occurrences = countOccurrences(text, token);

      if (inTitle || inSection || inKeywords || occurrences) originalMatches++;
      if (inTitle) score += 24;
      if (inSection) score += 19;
      if (inKeywords) score += 17;
      if (occurrences) score += Math.min(occurrences, 5) * 7;
      frequency += occurrences;
    });

    expandedTokens.forEach(function (token) {
      if (title.indexOf(token) !== -1 || sectionTitle.indexOf(token) !== -1 || keywords.indexOf(token) !== -1 || text.indexOf(token) !== -1) {
        expandedMatches++;
      }
    });

    var coverage = originalTokens.length ? originalMatches / originalTokens.length : 0;
    if (originalTokens.length > 1 && originalMatches === originalTokens.length) score += 65;
    score += Math.round(coverage * 50);
    score += Math.min(expandedMatches, 4) * 8;
    score += Math.min(frequency, 8) * 2;

    var qualifies = false;
    if (phrase && (title.indexOf(phrase) !== -1 || sectionTitle.indexOf(phrase) !== -1 || keywords.indexOf(phrase) !== -1 || text.indexOf(phrase) !== -1)) {
      qualifies = true;
    } else if (originalTokens.length === 1) {
      qualifies = originalMatches === 1 || expandedMatches > 0;
    } else if (originalTokens.length > 1) {
      qualifies = coverage >= 0.67 || (coverage >= 0.5 && expandedMatches > 0);
    }

    return {
      qualifies: qualifies,
      score: score,
      matchType: matchType || (expandedMatches ? 'alias' : 'content')
    };
  }

  function searchSmartIndex(index, rawQuery, labels, synonymMap) {
    var queryData = expandQuery(rawQuery, synonymMap);
    queryData.raw = rawQuery;
    var results = [];

    (index || []).forEach(function (page) {
      var best = null;

      (page.sections || []).forEach(function (section) {
        var scored = scoreSection(page, section, queryData);
        if (!scored.qualifies) return;
        if (!best || scored.score > best.score) {
          best = {
            score: scored.score,
            matchType: scored.matchType,
            section: section
          };
        }
      });

      if (!best) return;

      var location = labels.insideGuide;
      if (best.matchType === 'title') location = labels.titleMatch;
      else if (best.matchType === 'alias') location = labels.aliasMatch;

      if (best.section.title) {
        location += ' · ' + labels.sectionPrefix + ': ' + best.section.title;
      }

      results.push({
        title: page.title,
        label: page.title,
        href: page.url,
        category: page.category || labels.categoryFallback,
        location: location,
        snippet: makeSnippet(best.section.text, queryData, 240),
        score: best.score
      });
    });

    return results.sort(function (a, b) {
      return b.score - a.score || a.title.localeCompare(b.title);
    }).slice(0, SMART_LIMIT);
  }



  function ensureSearchHighlightStyles() {
    if (document.getElementById('oroza-search-highlight-styles')) return;

    var style = document.createElement('style');
    style.id = 'oroza-search-highlight-styles';
    style.textContent =
      '.oroza-search-highlight{' +
        'display:inline;' +
        'padding:1px 3px;' +
        'border-radius:4px;' +
        'background:linear-gradient(180deg,#fff0a6 0%,#ffd84d 100%);' +
        'color:#17324f!important;' +
        'font-weight:800;' +
        'box-shadow:inset 0 -1px 0 rgba(185,132,0,.22);' +
        'text-decoration:none;' +
      '}' +
      '.oroza-search-result-title .oroza-search-highlight{' +
        'padding:1px 4px;' +
        'background:linear-gradient(180deg,#ffe982 0%,#ffc928 100%);' +
      '}' +
      '.oroza-search-result-link[aria-selected="true"] .oroza-search-highlight{' +
        'background:#fff4bd;' +
        'color:#132a43!important;' +
      '}' +
      '@media (prefers-reduced-motion:reduce){' +
        '.oroza-search-highlight{transition:none;}' +
      '}';

    (document.head || document.documentElement).appendChild(style);
  }

  function foldTextWithMap(value) {
    var source = String(value || '');
    var folded = '';
    var map = [];

    for (var i = 0; i < source.length; i++) {
      var character = source.charAt(i);
      var normalized = character;

      try {
        normalized = character.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
      } catch (e) {}

      normalized = normalized
        .toLowerCase()
        .replace(/[\u2010-\u2015]/g, '-');

      for (var j = 0; j < normalized.length; j++) {
        folded += normalized.charAt(j);
        map.push(i);
      }
    }

    return { text: folded, map: map };
  }

  function getHighlightTerms(query) {
    var raw = String(query || '').replace(/\s+/g, ' ').trim();
    if (!raw) return [];

    var terms = [];
    var seen = Object.create(null);

    function addTerm(term) {
      term = String(term || '').trim();
      if (!term) return;

      var key = normalizeText(term);
      if (!key || seen[key]) return;

      seen[key] = true;
      terms.push(term);
    }

    addTerm(raw);

    var tokens = raw.split(/\s+/);
    tokens.forEach(function (token) {
      var clean = token.replace(/^[^\w@+#áéíóúüñçàèìòùâêîôûäëïöü]+|[^\w@+#áéíóúüñçàèìòùâêîôûäëïöü]+$/gi, '');
      if (!clean) return;

      /* Avoid painting common two-letter words in long questions, but keep
         useful short game terms such as IA, BG, HP or SP when searched alone. */
      if (clean.length >= 3 || tokens.length === 1) addTerm(clean);
    });

    return terms.sort(function (a, b) { return b.length - a.length; });
  }

  function findHighlightRanges(text, query) {
    var source = String(text || '');
    var terms = getHighlightTerms(query);
    if (!source || !terms.length) return [];

    var foldedSource = foldTextWithMap(source);
    var ranges = [];

    terms.forEach(function (term) {
      var foldedTerm = foldTextWithMap(term).text;
      if (!foldedTerm) return;

      var position = 0;
      while ((position = foldedSource.text.indexOf(foldedTerm, position)) !== -1) {
        var start = foldedSource.map[position];
        var lastMapIndex = position + foldedTerm.length - 1;
        var end = typeof foldedSource.map[lastMapIndex] === 'number'
          ? foldedSource.map[lastMapIndex] + 1
          : start + term.length;

        ranges.push({ start: start, end: end });
        position += Math.max(foldedTerm.length, 1);

        if (ranges.length >= 60) return;
      }
    });

    ranges.sort(function (a, b) {
      return a.start - b.start || b.end - a.end;
    });

    var merged = [];
    ranges.forEach(function (range) {
      var previous = merged.length ? merged[merged.length - 1] : null;

      if (!previous || range.start > previous.end) {
        merged.push({ start: range.start, end: range.end });
      } else if (range.end > previous.end) {
        previous.end = range.end;
      }
    });

    return merged;
  }

  function appendHighlightedText(parent, text, query) {
    var source = String(text || '');
    var ranges = findHighlightRanges(source, query);

    if (!ranges.length) {
      parent.appendChild(document.createTextNode(source));
      return parent;
    }

    var cursor = 0;
    ranges.forEach(function (range) {
      if (range.start > cursor) {
        parent.appendChild(document.createTextNode(source.slice(cursor, range.start)));
      }

      var mark = document.createElement('mark');
      mark.className = 'oroza-search-highlight';
      mark.textContent = source.slice(range.start, range.end);
      parent.appendChild(mark);
      cursor = range.end;
    });

    if (cursor < source.length) {
      parent.appendChild(document.createTextNode(source.slice(cursor)));
    }

    return parent;
  }

  function createElement(tag, className, text) {
    var element = document.createElement(tag);
    if (className) element.className = className;
    if (typeof text !== 'undefined') element.textContent = text;
    return element;
  }

  function initSearch(root) {
    var widget = root.querySelector ? root.querySelector(SELECTOR) : null;
    if (!widget || widget.dataset.orozaSmartReady === '23') return;
    widget.dataset.orozaSmartReady = '23';

    var form = widget.querySelector('.oroza-global-search-form');
    var input = widget.querySelector('.oroza-global-search-input');
    var results = widget.querySelector('.oroza-global-search-results');
    var clearButton = widget.querySelector('[data-oroza-search-clear]');
    var submitText = widget.querySelector('.oroza-global-search-submit span:last-child');
    var helper = widget.querySelector('.oroza-global-search-helper');

    if (!form || !input || !results || !clearButton) return;

    ensureSearchHighlightStyles();

    var labels = getLocale();
    var isEnglish = window.location.hostname === 'en.orozaro.com';
    var synonymMap = getSynonymMap(isEnglish);
    var menuEntries = collectMenuEntries();
    var categoryMap = buildCategoryMap(menuEntries);
    var apiCache = Object.create(null);
    var debounceTimer = null;
    var requestSequence = 0;
    var activeIndex = -1;
    var api = (window.mw && mw.Api) ? new mw.Api() : null;

    input.placeholder = labels.placeholder;
    input.setAttribute('aria-label', labels.ariaLabel);
    if (submitText) submitText.textContent = labels.searchButton;
    if (helper) {
      helper.innerHTML = isEnglish
        ? 'Searches inside every guide, section and paragraph. Use <kbd>↑</kbd> <kbd>↓</kbd> and <kbd>Enter</kbd>.'
        : 'Busca dentro de cada guía, sección y párrafo. Usa <kbd>↑</kbd> <kbd>↓</kbd> y <kbd>Enter</kbd>.';
    }

    function getNativeSearchUrl(query) {
      try {
        var url = new URL(form.action, window.location.href);
        url.searchParams.set('title', 'Special:Search');
        url.searchParams.set('search', query);
        url.searchParams.set('fulltext', '1');
        return url.toString();
      } catch (e) {
        return '/wiki/index.php?title=Special:Search&fulltext=1&search=' + encodeURIComponent(query);
      }
    }

    function setOpen(isOpen) {
      results.hidden = !isOpen;
      input.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
      if (!isOpen) activeIndex = -1;
    }

    function clearResults() {
      while (results.firstChild) results.removeChild(results.firstChild);
      setOpen(false);
    }

    function addGroupTitle(text) {
      results.appendChild(createElement('div', 'oroza-search-group-title', text));
    }

    function addResultItem(item, query) {
      var link = createElement('a', 'oroza-search-result-link');
      link.href = item.href;
      link.setAttribute('role', 'option');
      link.setAttribute('aria-selected', 'false');

      var main = createElement('div', 'oroza-search-result-main');

      var title = createElement('span', 'oroza-search-result-title');
      appendHighlightedText(title, item.label || item.title, query);
      main.appendChild(title);

      if (item.location) {
        var location = createElement('span', 'oroza-search-result-location');
        appendHighlightedText(location, item.location, query);
        main.appendChild(location);
      }

      if (item.snippet) {
        var safeSnippet = cleanSearchCodeResidue(stripHtml(item.snippet));
        if (safeSnippet) {
          var snippet = createElement('p', 'oroza-search-result-snippet');
          appendHighlightedText(snippet, safeSnippet, query);
          main.appendChild(snippet);
        }
      }

      link.appendChild(main);
      link.appendChild(createElement('span', 'oroza-search-result-category', item.category || labels.categoryFallback));
      results.appendChild(link);
    }

    function addSuggestion(suggestion) {
      if (!suggestion) return;
      var box = createElement('div', 'oroza-search-suggestion');
      box.appendChild(document.createTextNode(labels.suggestion + ': '));
      var button = createElement('button', '', suggestion);
      button.type = 'button';
      button.addEventListener('click', function () {
        input.value = suggestion;
        input.dispatchEvent(new Event('input', { bubbles: true }));
        input.focus();
      });
      box.appendChild(button);
      results.appendChild(box);
    }

    function addFooter(query, totalHits, indexState) {
      var footer = createElement('div', 'oroza-search-footer');
      var left = createElement('span', 'oroza-search-footer-count');
      var countText = '';

      if (typeof totalHits === 'number') {
        countText = totalHits + ' ' + (totalHits === 1 ? labels.resultSingular : labels.resultPlural);
      }

      left.textContent = countText;
      footer.appendChild(left);

      if (indexState) {
        footer.appendChild(createElement('span', 'oroza-search-index-state', indexState));
      }

      var link = createElement('a', '', labels.viewAll + ' →');
      link.href = getNativeSearchUrl(query);
      footer.appendChild(link);
      results.appendChild(footer);
    }

    function renderLoading(menuMatches, query) {
      while (results.firstChild) results.removeChild(results.firstChild);
      if (menuMatches.length) {
        addGroupTitle(labels.menuGroup);
        menuMatches.forEach(function (item) {
          addResultItem({
            title: item.title,
            label: item.label,
            href: item.href,
            category: item.category,
            location: labels.titleMatch,
            snippet: ''
          }, query);
        });
      }
      var status = createElement('div', 'oroza-search-status');
      status.appendChild(createElement('span', 'oroza-search-spinner'));
      status.appendChild(createElement('span', '', labels.loading));
      results.appendChild(status);
      setOpen(true);
    }

    function renderState(query, state) {
      while (results.firstChild) results.removeChild(results.firstChild);

      var seen = Object.create(null);
      var smartResults = [];
      var apiResults = [];
      var menuMatches = [];

      (state.smart || []).forEach(function (item) {
        var key = normalizeText(item.title);
        if (!key || seen[key]) return;
        seen[key] = true;
        smartResults.push(item);
      });

      (state.api || []).forEach(function (item) {
        var key = normalizeText(item.title);
        if (!key || seen[key]) return;
        seen[key] = true;
        apiResults.push(item);
      });

      (state.menu || []).forEach(function (item) {
        var key = normalizeText(item.title);
        if (!key || seen[key]) return;
        seen[key] = true;
        menuMatches.push(item);
      });

      if (smartResults.length) {
        addGroupTitle(labels.smartGroup);
        smartResults.forEach(function (item) { addResultItem(item, query); });
      }

      if (apiResults.length) {
        addGroupTitle(labels.apiGroup);
        apiResults.forEach(function (item) { addResultItem(item, query); });
      }

      if (menuMatches.length) {
        addGroupTitle(labels.menuGroup);
        menuMatches.forEach(function (item) {
          addResultItem({
            title: item.title,
            label: item.label,
            href: item.href,
            category: item.category,
            location: labels.titleMatch,
            snippet: ''
          }, query);
        });
      }

      if (!smartResults.length && !apiResults.length && !menuMatches.length) {
        var empty = createElement('div', 'oroza-search-empty');
        empty.appendChild(createElement('span', 'oroza-search-empty-icon', '🔍'));
        empty.appendChild(createElement('p', 'oroza-search-empty-title', labels.noResultsTitle));
        empty.appendChild(createElement('p', 'oroza-search-empty-text', labels.noResultsText));
        results.appendChild(empty);
      }

      if (state.smartLoading) {
        var loading = createElement('div', 'oroza-search-smart-loading');
        loading.appendChild(createElement('span', 'oroza-search-spinner'));
        loading.appendChild(createElement('span', '', labels.smartLoading));
        results.appendChild(loading);
      }

      if (state.apiFailed && !apiResults.length) {
        var warning = createElement('div', 'oroza-search-inline-warning');
        warning.textContent = labels.apiErrorTitle + '. ' + labels.apiErrorText;
        results.appendChild(warning);
      }

      addSuggestion(state.suggestion);

      var total = typeof state.totalHits === 'number'
        ? Math.max(state.totalHits, smartResults.length)
        : smartResults.length + apiResults.length + menuMatches.length;
      var indexState = '';
      if (typeof state.indexCount === 'number') indexState = state.indexCount + ' ' + labels.indexedGuides;
      else if (state.smartFailed) indexState = labels.indexUnavailable;

      addFooter(query, total, indexState);
      setOpen(true);
    }

    function transformApiData(data) {
      var query = data && data.query ? data.query : {};
      var searchInfo = query.searchinfo || {};
      var items = Array.isArray(query.search) ? query.search : [];

      return {
        totalHits: typeof searchInfo.totalhits === 'number' ? searchInfo.totalhits : items.length,
        suggestion: searchInfo.suggestion || '',
        items: items.map(function (item) {
          var category = categoryMap[normalizeText(item.title)] || labels.categoryFallback;
          var section = cleanSearchCodeResidue(stripHtml(item.sectiontitle || ''));
          var snippet = cleanSearchCodeResidue(stripHtml(item.snippet || ''));
          var location = labels.insideGuide;

          if (section) location += ' · ' + labels.sectionPrefix + ': ' + section;

          return {
            title: item.title,
            label: item.title,
            href: (window.mw && mw.util) ? mw.util.getUrl(item.title) : '/wiki/index.php?title=' + encodeURIComponent(item.title.replace(/ /g, '_')),
            category: category,
            location: location,
            snippet: snippet
          };
        })
      };
    }

    function searchNative(query) {
      var key = normalizeText(query);
      if (apiCache[key]) return Promise.resolve(apiCache[key]);
      if (!api) return Promise.reject(new Error('MediaWiki API unavailable'));

      return api.get({
        action: 'query',
        list: 'search',
        srsearch: query,
        srwhat: 'text',
        srnamespace: 0,
        srlimit: API_LIMIT,
        srinfo: 'totalhits|suggestion|rewrittenquery',
        srprop: 'snippet|titlesnippet|sectiontitle|redirecttitle',
        srenablerewrites: 1,
        formatversion: 2,
        utf8: 1
      }).then(function (data) {
        var payload = transformApiData(data);
        apiCache[key] = payload;
        return payload;
      });
    }

    function runSearch(rawQuery) {
      var query = String(rawQuery || '').trim();
      if (query.length < MIN_QUERY_LENGTH) {
        clearResults();
        return;
      }

      var currentRequest = ++requestSequence;
      var state = {
        menu: findMenuMatches(menuEntries, query),
        smart: [],
        api: [],
        smartLoading: true,
        smartFailed: false,
        apiFailed: false,
        suggestion: '',
        totalHits: null,
        indexCount: null
      };

      renderLoading(state.menu, query);

      searchNative(query).then(function (payload) {
        if (currentRequest !== requestSequence) return;
        state.api = payload.items;
        state.suggestion = payload.suggestion;
        state.totalHits = payload.totalHits;
        renderState(query, state);
      }).catch(function () {
        if (currentRequest !== requestSequence) return;
        state.apiFailed = true;
        renderState(query, state);
      });

      ensureSmartIndex(api, categoryMap).then(function (index) {
        if (currentRequest !== requestSequence) return;
        state.smart = searchSmartIndex(index, query, labels, synonymMap);
        state.smartLoading = false;
        state.indexCount = index.length;
        renderState(query, state);
      }).catch(function () {
        if (currentRequest !== requestSequence) return;
        state.smartLoading = false;
        state.smartFailed = true;
        renderState(query, state);
      });
    }

    function getResultLinks() {
      return Array.prototype.slice.call(results.querySelectorAll('.oroza-search-result-link'));
    }

    function setActiveResult(index) {
      var links = getResultLinks();
      if (!links.length) return;

      if (index < 0) index = links.length - 1;
      if (index >= links.length) index = 0;
      activeIndex = index;

      links.forEach(function (link, itemIndex) {
        var active = itemIndex === activeIndex;
        link.classList.toggle('is-active', active);
        link.setAttribute('aria-selected', active ? 'true' : 'false');
        if (active) link.scrollIntoView({ block: 'nearest' });
      });
    }

    input.addEventListener('input', function () {
      var query = input.value.trim();
      clearButton.hidden = !query;
      activeIndex = -1;
      window.clearTimeout(debounceTimer);

      if (query.length < MIN_QUERY_LENGTH) {
        requestSequence++;
        clearResults();
        return;
      }

      debounceTimer = window.setTimeout(function () {
        runSearch(query);
      }, DEBOUNCE_MS);
    });

    input.addEventListener('focus', function () {
      if (input.value.trim().length >= MIN_QUERY_LENGTH && results.hidden) {
        runSearch(input.value);
      }
    });

    input.addEventListener('keydown', function (event) {
      if (event.key === 'ArrowDown') {
        event.preventDefault();
        if (results.hidden) runSearch(input.value);
        setActiveResult(activeIndex + 1);
      } else if (event.key === 'ArrowUp') {
        event.preventDefault();
        setActiveResult(activeIndex - 1);
      } else if (event.key === 'Enter' && activeIndex >= 0) {
        var links = getResultLinks();
        if (links[activeIndex]) {
          event.preventDefault();
          window.location.href = links[activeIndex].href;
        }
      } else if (event.key === 'Escape') {
        setOpen(false);
        input.blur();
      }
    });

    clearButton.addEventListener('click', function () {
      input.value = '';
      clearButton.hidden = true;
      requestSequence++;
      window.clearTimeout(debounceTimer);
      clearResults();
      input.focus();
    });

    document.addEventListener('click', function (event) {
      if (!widget.contains(event.target)) setOpen(false);
    });

    form.addEventListener('submit', function (event) {
      if (!input.value.trim()) {
        event.preventDefault();
        input.focus();
      }
    });
  }

  function boot(root) {
    root = root || document;
    var widgets = root.querySelectorAll ? root.querySelectorAll(SELECTOR) : [];
    Array.prototype.forEach.call(widgets, function (widget) {
      initSearch(widget.parentNode || document);
    });
  }

  function bootWithModules(root) {
    if (window.mw && mw.loader && mw.loader.using) {
      mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(function () {
        boot(root);
      }, function () {
        boot(root);
      });
    } else {
      boot(root);
    }
  }

  window.OrozaWikiSearch = {
    rebuild: function () {
      clearIndexCache();
      return true;
    },
    clearCache: clearIndexCache
  };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', function () {
      bootWithModules(document);
    });
  } else {
    bootWithModules(document);
  }

  if (window.mw && mw.hook) {
    mw.hook('wikipage.content').add(function ($content) {
      var node = $content && $content[0] ? $content[0] : document;
      bootWithModules(node);
      bootWithModules(document);
    });
  }
})();