PageSourceSearch

https://pixelfield.co.uk/_assets/2026/js/site-search.js?v=1.2.1

js pixelfield.co.uk collected 2026-10-02 05:42:31 UTC 12,538 bytes, 259 lines download raw bytes

1/**
2 * Pixelfield site search (v1.2.1) — styl podle pf-menu design systému
3 * Additive, self-contained: injects a search button into .pf-header__actions,
4 * renders a full-screen overlay, searches the static page index
5 * (/_assets/2026/search-index.json) plus live blog posts via WP REST.
6 * No dependencies. Remove this <script> tag to remove the feature entirely.
7 */
8(function () {
9  'use strict';
10  if (window.__pfSearchLoaded) return;
11  window.__pfSearchLoaded = true;
12
13  var INDEX_URL = '/_assets/2026/search-index.json';
14  var BLOG_API = '/blog/wp-json/wp/v2/posts';
15  var index = null;
16
17  function norm(s) {
18    return (s || '')
19      .toLowerCase()
20      .normalize('NFD')
21      .replace(/[̀-ͯ]/g, '');
22  }
23
24  var MONO = '"Tronica Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
25  var SANS = '"PP Neue Montreal", "grotesk-w02-fallback-sans-serif", system-ui, sans-serif';
26  var css =
27    '.pf-search-btn{display:flex;align-items:center;gap:4px;background:none;border:0;padding:0;cursor:pointer;' +
28    'font-size:14px;font-family:' + MONO + ';font-weight:400;text-transform:uppercase;line-height:22px;transition:color .3s ease}' +
29    '.pf-search-btn:hover{color:#dcfe7e!important}' +
30    '.pf-search-overlay{position:fixed;inset:0;z-index:9999;background:#141e0a;overflow-y:auto;-webkit-overflow-scrolling:touch;' +
31    'visibility:hidden;opacity:0;pointer-events:none;transition:visibility .3s ease,opacity .3s ease}' +
32    '.pf-search-overlay.is-open{visibility:visible;opacity:1;pointer-events:auto}' +
33    '.pf-search-watermark{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);font-size:clamp(80px,15vw,160px);' +
34    'font-family:' + MONO + ';font-weight:400;text-transform:uppercase;color:rgba(243,243,239,.05);pointer-events:none;white-space:nowrap}' +
35    '.pf-search-inner{min-height:100%;padding:120px 40px 80px;max-width:1120px;margin:0 auto;position:relative}' +
36    '.pf-search-row{display:flex;align-items:center;gap:24px}' +
37    '.pf-search-input{flex:1;background:none;border:0;border-bottom:1px solid rgba(243,243,239,.3);outline:0;border-radius:0;' +
38    'color:#f3f3ef;caret-color:#dcfe7e;font-size:32px;line-height:1.4;min-width:0;padding:0 0 12px;' +
39    'font-family:' + SANS + ';font-weight:400;transition:border-color .2s ease}' +
40    '.pf-search-input:focus{border-bottom-color:#dcfe7e}' +
41    '.pf-search-input::placeholder{color:rgba(243,243,239,.4)}' +
42    '.pf-search-input::-webkit-search-cancel-button{display:none}' +
43    '.pf-search-close{background:none;border:0;cursor:pointer;color:rgba(243,243,239,.7);font-size:14px;' +
44    'font-family:' + MONO + ';font-weight:400;text-transform:uppercase;line-height:22px;padding:0;transition:color .2s ease}' +
45    '.pf-search-close:hover{color:#dcfe7e}' +
46    '.pf-search-hint{margin-top:12px;color:rgba(243,243,239,.4);font-size:12px;font-family:' + MONO + ';text-transform:uppercase;letter-spacing:.08em}' +
47    '.pf-search-results{margin-top:36px;position:relative}' +
48    '.pf-search-group{display:block;color:rgba(243,243,239,.7);font-size:12px;font-family:' + MONO + ';font-weight:400;' +
49    'text-transform:uppercase;letter-spacing:.08em;margin:40px 0 20px}' +
50    '.pf-search-group:first-child{margin-top:0}' +
51    '.pf-search-item{display:block;padding:10px 0;color:#f3f3ef;text-decoration:none;font-family:' + SANS + ';transition:color .2s ease}' +
52    '.pf-search-item b{font-weight:400;font-size:18px;line-height:1.4}' +
53    '.pf-search-item:hover,.pf-search-item.is-active{color:#dcfe7e}' +
54    '.pf-search-item span{display:block;font-size:13px;color:rgba(243,243,239,.5);margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
55    '.pf-search-item--all{font-family:' + MONO + ';font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#dcfe7e}' +
56    '.pf-search-item--all:hover{color:#f3f3ef}' +
57    '.pf-search-item--all span{display:inline;color:inherit;font-size:12px}' +
58    '.pf-search-empty{margin-top:48px;color:rgba(243,243,239,.5);font-family:' + SANS + '}' +
59    '@media (max-width:600px){.pf-search-inner{padding:96px 20px 60px}.pf-search-input{font-size:20px;padding-bottom:8px}}';
60
61  var style = document.createElement('style');
62  style.textContent = css;
63  document.head.appendChild(style);
64
65  var overlay = document.createElement('div');
66  overlay.className = 'pf-search-overlay';
67  overlay.setAttribute('role', 'dialog');
68  overlay.setAttribute('aria-modal', 'true');
69  overlay.setAttribute('aria-label', 'Site search');
70  overlay.innerHTML =
71    '<div class="pf-search-watermark" aria-hidden="true">Search</div>' +
72    '<div class="pf-search-inner">' +
73    '<div class="pf-search-row">' +
74    '<input class="pf-search-input" type="search" placeholder="Search services and articles…" autocomplete="off" spellcheck="false">' +
75    '<button class="pf-search-close" type="button" aria-label="Close search">Close</button>' +
76    '</div><div class="pf-search-hint">Enter \u2192 open best match \u00b7 \u2191\u2193 pick \u00b7 Esc close</div>' +
77    '<div class="pf-search-results" aria-live="polite"></div></div>';
78
79  var input, results;
80
81  function ensureMounted() {
82    if (!overlay.parentNode) {
83      document.body.appendChild(overlay);
84      input = overlay.querySelector('.pf-search-input');
85      results = overlay.querySelector('.pf-search-results');
86      input.addEventListener('input', onType);
87      input.addEventListener('keydown', onKey);
88      overlay.querySelector('.pf-search-close').addEventListener('click', close);
89      overlay.addEventListener('click', function (e) {
90        if (e.target === overlay) close();
91      });
92    }
93  }
94
95  function open() {
96    ensureMounted();
97    overlay.classList.add('is-open');
98    document.documentElement.style.overflow = 'hidden';
99    input.focus();
100    if (!index) {
101      fetch(INDEX_URL).then(function (r) { return r.json(); }).then(function (d) { index = d; if (input.value.trim().length > 1) onType(); }).catch(function () { index = []; });
102    }
103  }
104
105  function close() {
106    overlay.classList.remove('is-open');
107    document.documentElement.style.overflow = '';
108  }
109
110  document.addEventListener('keydown', function (e) {
111    if (e.key === 'Escape' && overlay.classList.contains('is-open')) close();
112  });
113
114  function esc(s) {
115    var d = document.createElement('div');
116    d.textContent = s || '';
117    return d.innerHTML;
118  }
119
120  var STOP = { the: 1, a: 1, an: 1, and: 1, of: 1, for: 1, in: 1, to: 1, uk: 1, london: 1, company: 0, agency: 0 };
121
122  function scorePage(it, qWords, qPhrase) {
123    var t = norm(it.t), h = norm(it.h), d = norm(it.d), u = norm(it.u);
124    var s = 0;
125    // 1) presna shoda cele fraze
126    if (t === qPhrase || h === qPhrase) s += 100;
127    else if (t.indexOf(qPhrase) === 0 || h.indexOf(qPhrase) === 0) s += 60;
128    else if (t.indexOf(qPhrase) !== -1 || h.indexOf(qPhrase) !== -1) s += 40;
129    else if (d.indexOf(qPhrase) !== -1) s += 15;
130    // 2) jednotliva slova (stop-slova ignorovana)
131    var matched = 0;
132    qWords.forEach(function (w) {
133      if (!w || STOP[w] === 1) return;
134      var hit = false;
135      if (t.indexOf(w) !== -1) { s += 8; hit = true; }
136      if (h.indexOf(w) !== -1) { s += 4; hit = true; }
137      if (d.indexOf(w) !== -1) { s += 2; hit = true; }
138      if (u.indexOf(w) !== -1) { s += 2; hit = true; }
139      if (hit) matched++;
140    });
141    var meaningful = qWords.filter(function (w) { return w && STOP[w] !== 1; }).length;
142    if (meaningful && matched < meaningful) s = s * (matched / meaningful) * 0.6; // chybi slovo -> penalizace
143    // 2b) fraze primo ve slugu URL (ai-agents-development pro "ai agents")
144    var slugPhrase = qPhrase.replace(/ /g, '-');
145    if (slugPhrase.length > 3 && u.indexOf(slugPhrase) !== -1) s += 45;
146    // 3) preference: kratsi titul = obecnejsi stranka (app development > android app development)
147    if (s > 0) s += Math.max(0, 40 - t.length) * 0.5;
148    // 4) hlavni servisni stranky (kratke URL) vys nez podstranky a case studies
149    var depth = (u.match(/\//g) || []).length;
150    if (u.indexOf('/our-work/') === 0) s -= 10;
151    if (u.indexOf('/career') === 0) s -= 15;
152    if (depth <= 2) s += 6;
153    return s;
154  }
155
156  var blogTimer = null, lastQuery = '';
157
158  function onType() {
159    var raw = input.value.trim();
160    lastQuery = raw;
161    if (raw.length < 2) { results.innerHTML = ''; return; }
162    var qPhrase = norm(raw).replace(/\s+/g, ' ');
163    var q = qPhrase.split(' ');
164    var pages = (index || [])
165      .map(function (it) { return { it: it, s: scorePage(it, q, qPhrase) }; })
166      .filter(function (x) { return x.s > 0; })
167      .sort(function (a, b) { return b.s - a.s; })
168      .slice(0, 7);
169    render(pages, null, raw);
170    clearTimeout(blogTimer);
171    blogTimer = setTimeout(function () { searchBlog(raw, pages); }, 300);
172  }
173
174  function searchBlog(raw, pages) {
175    fetch(BLOG_API + '?search=' + encodeURIComponent(raw) + '&per_page=5&_fields=title,link')
176      .then(function (r) { return r.ok ? r.json() : []; })
177      .then(function (posts) {
178        if (raw !== lastQuery) return;
179        render(pages, posts, raw);
180      })
181      .catch(function () {});
182  }
183
184  function render(pages, posts, raw) {
185    var htmlOut = '';
186    if (pages.length) {
187      htmlOut += '<div class="pf-search-group">Services &amp; pages</div>';
188      pages.forEach(function (x) {
189        htmlOut += '<a class="pf-search-item" href="' + esc(x.it.u) + '"><b>' + esc(x.it.t) + '</b><span>' + esc(x.it.d || x.it.h) + '</span></a>';
190      });
191    }
192    if (posts && posts.length) {
193      htmlOut += '<div class="pf-search-group">From the blog</div>';
194      posts.forEach(function (p) {
195        var t = p.title && p.title.rendered ? p.title.rendered.replace(/<[^>]+>/g, '') : '';
196        htmlOut += '<a class="pf-search-item pf-search-item--blog" href="' + esc(p.link) + '"><b>' + t + '</b></a>';
197      });
198    }
199    if (posts) {
200      htmlOut += '<a class="pf-search-item pf-search-item--all" href="/blog/?s=' + encodeURIComponent(raw) + '"><span>Search the whole blog for “' + esc(raw) + '” →</span></a>';
201    }
202    if (!htmlOut) htmlOut = '<div class="pf-search-empty">No results yet…</div>';
203    results.innerHTML = htmlOut;
204  }
205
206  function onKey(e) {
207    var items = results.querySelectorAll('.pf-search-item');
208    if (!items.length && e.key !== 'Enter') return;
209    var active = results.querySelector('.pf-search-item.is-active');
210    var idx = Array.prototype.indexOf.call(items, active);
211    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
212      e.preventDefault();
213      if (active) active.classList.remove('is-active');
214      idx = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
215      items[idx].classList.add('is-active');
216      items[idx].scrollIntoView({ block: 'nearest' });
217    } else if (e.key === 'Enter') {
218      e.preventDefault();
219      // Enter = nejlepsi servisni stranka (aktivni polozka, jinak prvni stranka ze sekce Services)
220      var target = active || results.querySelector('.pf-search-item:not(.pf-search-item--all):not(.pf-search-item--blog)');
221      if (target) { window.location.href = target.getAttribute('href'); return; }
222      var raw = input.value.trim();
223      if (raw.length > 1) window.location.href = '/blog/?s=' + encodeURIComponent(raw);
224    }
225  }
226
227  function injectButton() {
228    var actions = document.querySelector('.pf-header__actions');
229    if (!actions || actions.querySelector('.pf-search-btn')) return;
230    var btn = document.createElement('button');
231    btn.type = 'button';
232    btn.className = 'pf-search-btn';
233    btn.setAttribute('aria-label', 'Search');
234    btn.innerHTML = '<span class="pf-search-btn__label">Search</span>';
235    btn.addEventListener('click', open);
236    actions.insertBefore(btn, actions.firstChild);
237
238    // Barvu ikony přebírej z vedlejšiho MENU labelu (hlavicka meni tema pri scrollu)
239    var label = document.querySelector('.pf-header__menu-label') || actions;
240    var ticking = false;
241    function syncColor() {
242      btn.style.color = getComputedStyle(label).color;
243      ticking = false;
244    }
245    syncColor();
246    window.addEventListener('scroll', function () {
247      if (!ticking) { ticking = true; requestAnimationFrame(syncColor); }
248    }, { passive: true });
249    window.addEventListener('resize', function () {
250      if (!ticking) { ticking = true; requestAnimationFrame(syncColor); }
251    }, { passive: true });
252  }
253
254  if (document.readyState === 'loading') {
255    document.addEventListener('DOMContentLoaded', injectButton);
256  } else {
257    injectButton();
258  }
259})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.