1(function () { 2 const initialSlug = GWJ_BLOG.startTag || ''; 3 const initialTagId = (() => { 4 const mapped = initialSlug && GWJ_BLOG.tagMap ? GWJ_BLOG.tagMap[initialSlug] : null; 5 if (mapped) return String(mapped); 6 if (GWJ_BLOG.startTagId) return String(GWJ_BLOG.startTagId); 7 return ''; 8 })(); 9 10 const S = { 11 state: { 12 tagSlug: initialSlug, 13 tagId: initialTagId, 14 page: 1, 15 search: '', 16 loading: false, 17 done: false, 18 totalPages: null, 19 allowInfinite: true, 20 cache: new Map(), // key: `${tag}|${search}|${page}` 21 }, 22 els: {} 23 }; 24 25 function qs(s, r=document){return r.querySelector(s);} 26 function qsa(s, r=document){return Array.from(r.querySelectorAll(s));} 27 28 function resolveTagId(slug) { 29 if (!slug || !GWJ_BLOG.tagMap) return ''; 30 const id = GWJ_BLOG.tagMap[slug]; 31 return id ? String(id) : ''; 32 } 33 34 function resolveSlugById(id) { 35 if (!id) return ''; 36 if (GWJ_BLOG.tagReverseMap && GWJ_BLOG.tagReverseMap[id]) { 37 return GWJ_BLOG.tagReverseMap[id]; 38 } 39 if (GWJ_BLOG.tagMap) { 40 for (const [slug, value] of Object.entries(GWJ_BLOG.tagMap)) { 41 if (String(value) === String(id)) { 42 return slug; 43 } 44 } 45 } 46 return ''; 47 } 48 49 function setLoader(active) { 50 if (!S.els.loader) return; 51 S.els.loader.classList.toggle('is-active', active); 52 if (active) { 53 S.els.loader.removeAttribute('hidden'); 54 } else { 55 S.els.loader.setAttribute('hidden', ''); 56 } 57 S.els.loader.setAttribute('aria-busy', active ? 'true' : 'false'); 58 } 59 60 function buildUrl({tagSlug, tagId, page, search}) { 61 const params = new URLSearchParams({ 62 per_page: GWJ_BLOG.perPage, 63 page, 64 _embed: '1', 65 order: 'desc', 66 orderby: 'date' 67 }); 68 if (GWJ_BLOG.lang) params.set('lang', GWJ_BLOG.lang); 69 if (search) params.set('search', search); 70 if (GWJ_BLOG.category) params.set('categories', GWJ_BLOG.category); 71 const activeTagId = tagId || resolveTagId(tagSlug); 72 if (activeTagId) params.set('tags', activeTagId); 73 return GWJ_BLOG.rest + '?' + params.toString(); 74 } 75 76 function stripHtml(html) { 77 if (!html) return ''; 78 const tmp = document.createElement('div'); 79 tmp.innerHTML = html; 80 return (tmp.textContent || tmp.innerText || '').trim(); 81 } 82 83 function truncateWords(text, limit) { 84 const words = text.split(/\s+/).filter(Boolean); 85 if (words.length <= limit) return text; 86 return words.slice(0, limit).join(' ') + 'â¦'; 87 } 88 89 function formatDate(dateString) { 90 if (!dateString) return ''; 91 const d = new Date(dateString); 92 if (Number.isNaN(d.getTime())) return ''; 93 return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); 94 } 95 96 function cardHTML(post) { 97 // Featured image (embedded) + author + date 98 const link = post.link;
99 const title = post.title.rendered || ''; 100 const titleText = stripHtml(title); 101 const excerpt = truncateWords(stripHtml(post.excerpt?.rendered || ''), 30); 102 const date = formatDate(post.date); 103 const media = post._embedded?.['wp:featuredmedia']?.[0]; 104 const src = media?.media_details?.sizes?.medium_large?.source_url || media?.source_url || ''; 105 const alt = media?.alt_text || ''; 106 const author = post._embedded?.author?.[0]?.name || ''; 107 const avatar = post._embedded?.author?.[0]?.avatar_urls?.['48'] 108 || post._embedded?.author?.[0]?.avatar_urls?.['96'] 109 || ''; 110 111 return ` 112 <article class="card"> 113 <a class="card__thumb" href="${link}" aria-label="${escapeHtml(titleText)}"> 114 ${src ? `<img src="${src}" alt="${escapeHtml(alt)}" loading="lazy" decoding="async">` : `<div class="ph"></div>`} 115 </a> 116 <div class="card__body"> 117 <div class="card__meta"> 118 ${avatar ? `<img class="card__avatar" src="${avatar}" alt="${escapeHtml(author)}" loading="lazy" decoding="async">` : ''} 119 <div class="card__meta-info"> 120 <span class="card__author">${escapeHtml(author)}</span> 121 ${date ? `<span class="card__dot" aria-hidden="true">•</span>` : ''} 122 ${date ? `<span class="card__date">${escapeHtml(date)}</span>` : ''} 123 </div> 124 </div> 125 <h3 class="card__title"><a href="${link}">${title}</a></h3> 126 <p class="card__excerpt">${escapeHtml(excerpt)}</p> 127 </div> 128 </article> 129 `; 130 } 131 132 function escapeHtml(s){return (s||'').replace(/[&<>"]/g, c=>({ '&':'&','<':'<','>':'>','"':'"'}[c]));} 133 134 function formatString(template, value) { 135 if (!template) return value; 136 return template.replace('%1$s', value).replace('%s', value); 137 } 138 139 function updateActiveChip() { 140 if (!S.els.filterChips) return; 141 S.els.filterChips.forEach(btn=>{ 142 const btnSlug = btn.dataset.tag || ''; 143 let isActive = false; 144 if (S.state.search) { 145 isActive = false; 146 } else if (S.state.tagSlug) { 147 isActive = btnSlug === S.state.tagSlug; 148 } else { 149 isActive = btnSlug === ''; 150 } 151 btn.classList.toggle('is-active', isActive); 152 }); 153 } 154 155 function renderEmptyState() { 156 if (!S.els.posts) return; 157 S.els.posts.innerHTML = `<p class="gwj-empty">${escapeHtml(GWJ_BLOG.i18n?.noResults || '')}</p>`; 158 } 159 160 function setSearchButtonLoading(active) { 161 const btn = S.els.searchButton; 162 if (!btn) return; 163 const defaultLabel = btn.dataset.label || btn.textContent.trim(); 164 btn.dataset.label = defaultLabel; 165 if (active) { 166 btn.disabled = true; 167 btn.classList.add('is-loading'); 168 btn.innerHTML = `<span class="gwj-spinner" aria-hidden="true"></span><span class="screen-reader-text">${escapeHtml(GWJ_BLOG.i18n?.searching || '')}</span>`; 169 } else { 170 btn.disabled = false; 171 btn.classList.remove('is-loading'); 172 btn.innerHTML = `<span class="gwj-search-button__label">${escapeHtml(defaultLabel)}</span>`; 173 } 174 } 175 176 async function fetchPage() { 177 if (S.state.totalPages && S.state.page > S.state.totalPages) { 178 S.state.done = true; 179 } 180 if (S.state.loading || S.state.done) return; 181 S.state.loading = true; 182 setLoader(true); 183 184 const cacheTagKey = S.state.tagId || S.state.tagSlug || ''; 185 const cacheLangKey = GWJ_BLOG.lang || ''; 186 const key = `${cacheLangKey}|${cacheTagKey}|${S.state.search}|${S.state.page}`; 187 if (S.state.cache.has(key)) { 188 const cached = S.state.cache.get(key); 189 const posts = Array.isArray(cached) ? cached : cached.posts; 190 const totalPages = Array.isArray(cached) ? null : cached.totalPages; 191 if (Number.isInteger(totalPages) && !S.state.totalPages) { 192 S.state.totalPages = totalPages; 193 } 194 if (Array.isArray(posts) && posts.length === 0 && S.state.page === 1) { 195 renderEmptyState(); 196 S.state.done = true; 197 } else { 198 renderAppend(posts); 199 if (Array.isArray(posts) && posts.length < GWJ_BLOG.perPage) { 200 S.state.done = true; 201 } 202 if (Number.isInteger(totalPages) && S.state.page >= totalPages) { 203 S.state.done = true; 204 } 205 } 206 S.state.loading = false;
207 setLoader(false); 208 return; 209 } 210 211 try { 212 const res = await fetch(buildUrl(S.state), { headers: { 'Accept': 'application/json' }}); 213 if (!res.ok) { 214 if (res.status === 400 || res.status === 422) S.state.done = true; 215 S.state.loading = false; return; 216 } 217 const totalPagesHeader = res.headers.get('X-WP-TotalPages'); 218 const totalPages = totalPagesHeader ? parseInt(totalPagesHeader, 10) : null; 219 if (Number.isInteger(totalPages)) { 220 S.state.totalPages = totalPages; 221 } 222 const posts = await res.json(); 223 S.state.cache.set(key, { posts, totalPages }); 224 if (!Array.isArray(posts) || posts.length === 0) { 225 if (S.state.page === 1) renderEmptyState(); 226 S.state.done = true; 227 } else { 228 renderAppend(posts); 229 if (posts.length < GWJ_BLOG.perPage) { 230 S.state.done = true; 231 } 232 if (Number.isInteger(totalPages) && S.state.page >= totalPages) { 233 S.state.done = true; 234 } 235 } 236 } catch (e) { 237 console.error(e); 238 } finally { 239 S.state.loading = false; 240 setLoader(false); 241 S.state.allowInfinite = !S.state.done; 242 } 243 } 244 245 function renderAppend(posts) { 246 if (!Array.isArray(posts) || posts.length === 0) return; 247 const html = posts.map(cardHTML).join(''); 248 S.els.posts.insertAdjacentHTML('beforeend', html); 249 S.els.posts.dataset.page = String(S.state.page); 250 } 251 252 function clearGrid() { 253 S.els.posts.innerHTML = ''; 254 S.state.page = 1; 255 S.state.done = false; 256 S.state.totalPages = null; 257 S.state.allowInfinite = false; 258 setLoader(false); 259 } 260 261 function getTagLabel(slug) { 262 if (!slug) return ''; 263 if (GWJ_BLOG.tagLabels && GWJ_BLOG.tagLabels[slug]) { 264 return GWJ_BLOG.tagLabels[slug]; 265 } 266 if (S.els.filterChips) { 267 const match = S.els.filterChips.find(btn => (btn.dataset.tag || '') === slug); 268 if (match) { 269 return match.textContent.trim(); 270 } 271 } 272 return ''; 273 } 274 275 function getTagDescription(slug) { 276 if (!slug || !GWJ_BLOG.tagDescriptions) return ''; 277 return GWJ_BLOG.tagDescriptions[slug] || ''; 278 } 279 280 function updateTitle() { 281 if (!S.els.title) return; 282 const slug = S.state.tagSlug; 283 const label = getTagLabel(slug); 284 const description = getTagDescription(slug); 285 let titleText = ''; 286 let descriptionText = ''; 287 288 if (slug) { 289 titleText = label || toTitle(slug); 290 descriptionText = description || ''; 291 } else if (S.state.search) { 292 titleText = formatString(GWJ_BLOG.i18n?.resultsFor || 'Results for "%s"', S.state.search); 293 } else { 294 titleText = GWJ_BLOG.i18n?.allGuides || 'All guides'; 295 } 296 297 S.els.title.textContent = titleText; 298 299 if (S.els.description) { 300 if (descriptionText) { 301 S.els.description.textContent = descriptionText; 302 S.els.description.hidden = false; 303 } else { 304 S.els.description.textContent = ''; 305 S.els.description.hidden = true; 306 } 307 } 308 } 309 310 function toTitle(slug){return slug.replace(/-/g,' ').replace(/\b\w/g, m=>m.toUpperCase());} 311 312 // URL handling (unique URLs per tag/search) 313 function pushUrl() { 314 const url = new URL(window.location.href); 315 url.searchParams.delete('s'); // we use our own 316 if (S.state.tagSlug) { 317 url.searchParams.set('tag', S.state.tagSlug); 318 } else { 319 url.searchParams.delete('tag'); 320 } 321 url.searchParams.delete('tag_id'); // legacy param cleanup 322 if (S.state.search) url.searchParams.set('q', S.state.search); else url.searchParams.delete('q'); 323 history.pushState(S.state, '', url); 324 } 325 326 function replaceUrlIfNeeded() { 327 const url = new URL(window.location.href); 328 url.searchParams.delete('s');
329 if (S.state.tagSlug) url.searchParams.set('tag', S.state.tagSlug); else url.searchParams.delete('tag'); 330 url.searchParams.delete('tag_id'); 331 if (S.state.search) url.searchParams.set('q', S.state.search); else url.searchParams.delete('q'); 332 history.replaceState(S.state, '', url); 333 } 334 335 function syncFromUrl({allowReplace=false} = {}) { 336 const url = new URL(window.location.href); 337 const tagSlug = url.searchParams.get('tag') || ''; 338 const legacyTagId = url.searchParams.get('tag_id') || ''; 339 const q = url.searchParams.get('q') || ''; 340 S.state.search = q; 341 342 const applyResolvedTag = (slug, id) => { 343 S.state.tagSlug = slug || ''; 344 S.state.tagId = id ? String(id) : ''; 345 }; 346 347 if (tagSlug) { 348 const resolvedId = resolveTagId(tagSlug); 349 if (resolvedId) { 350 applyResolvedTag(tagSlug, resolvedId); 351 } else if (GWJ_BLOG.startTagId) { 352 const localizedSlug = resolveSlugById(String(GWJ_BLOG.startTagId)) || GWJ_BLOG.startTag || ''; 353 applyResolvedTag(localizedSlug, GWJ_BLOG.startTagId); 354 if (allowReplace) replaceUrlIfNeeded(); 355 } else { 356 applyResolvedTag('', ''); 357 if (allowReplace) replaceUrlIfNeeded(); 358 } 359 } else if (legacyTagId) { 360 const mappedSlug = resolveSlugById(legacyTagId); 361 if (mappedSlug) { 362 applyResolvedTag(mappedSlug, resolveTagId(mappedSlug)); 363 if (allowReplace) replaceUrlIfNeeded(); 364 } else if (GWJ_BLOG.startTagId) { 365 const localizedSlug = resolveSlugById(String(GWJ_BLOG.startTagId)) || GWJ_BLOG.startTag || ''; 366 applyResolvedTag(localizedSlug, GWJ_BLOG.startTagId); 367 if (allowReplace) replaceUrlIfNeeded(); 368 } else { 369 applyResolvedTag('', ''); 370 if (allowReplace) replaceUrlIfNeeded(); 371 } 372 } else { 373 applyResolvedTag('', ''); 374 } 375 } 376 377 // Infinite scroll 378 function initInfinite() { 379 const io = new IntersectionObserver((entries)=>{ 380 entries.forEach(e=>{ 381 if (e.isIntersecting && S.state.allowInfinite && !S.state.loading && !S.state.done) { 382 S.state.page += 1; 383 fetchPage(); 384 } 385 }); 386 }, {root: null, rootMargin: '600px 0px 0px 0px', threshold: 0}); 387 io.observe(S.els.sentinel); 388 S.io = io; 389 } 390 391 // Filters (chips) 392 function initFilters() { 393 if (!S.els.filterChips) return; 394 S.els.filterChips.forEach(btn=>{ 395 btn.addEventListener('click', ()=>{ 396 S.state.tagSlug = btn.dataset.tag || ''; 397 S.state.tagId = btn.dataset.tagId || resolveTagId(S.state.tagSlug); 398 S.state.search = ''; 399 if (S.els.searchInput) { 400 S.els.searchInput.value = ''; 401 } 402 updateActiveChip(); 403 clearGrid(); updateTitle(); pushUrl(); fetchPage(); 404 }); 405 }); 406 } 407 408 // Search submission only 409 function initSearch() { 410 const input = S.els.searchInput; 411 const form = qs('#gwj-search-form'); 412 if (!input || !form) return; 413 form.addEventListener('submit', async (e)=>{ 414 e.preventDefault(); 415 const v = input.value.trim(); 416 S.state.search = v; 417 S.state.tagSlug = ''; 418 S.state.tagId = ''; 419 updateActiveChip(); 420 setSearchButtonLoading(true); 421 clearGrid(); updateTitle(); pushUrl(); 422 try { 423 await fetchPage(); 424 } finally { 425 setSearchButtonLoading(false); 426 } 427 }); 428 } 429 430 // Hydrate initial SSR page and set up everything 431 document.addEventListener('DOMContentLoaded', ()=>{ 432 S.els.root = qs('#gwj-blog'); 433 S.els.posts = qs('#gwj-posts'); 434 S.els.title = qs('#gwj-grid-title'); 435 S.els.description = qs('#gwj-grid-description'); 436 S.els.sentinel = qs('#gwj-sentinel'); 437 S.els.searchInput = qs('#gwj-search-input'); 438 S.els.filterChips = qsa('.gwj-grid__filters .chip'); 439 S.els.searchButton = qs('#gwj-search-button'); 440 S.els.loader = qs('#gwj-loader'); 441 442 if (S.els.searchButton && !S.els.searchButton.dataset.label) { 443 S.els.searchButton.dataset.label = S.els.searchButton.textContent.trim(); 444 } 445 446 syncFromUrl({ allowReplace: true }); 447 updateTitle(); 448 updateActiveChip(); 449 initFilters(); 450 initSearch(); 451 initInfinite(); 452 const initialCount = S.els.posts ? S.els.posts.querySelectorAll('.card').length : 0; 453 if (initialCount > 0 && initialCount < GWJ_BLOG.perPage) { 454 S.state.done = true; 455 } 456 S.state.allowInfinite = !S.state.done; 457 458 // If URL indicates a tag/search different from SSR, reload grid 459 const ssrSlug = initialSlug || ''; 460 const url = new URL(location.href); 461 const urlTagSlug = url.searchParams.get('tag') || ''; 462 const urlTagId = url.searchParams.get('tag_id') || ''; 463 const urlQ = url.searchParams.get('q') || ''; 464 const needsInitialFetch = initialCount === 0; 465 const legacySlug = urlTagId ? resolveSlugById(urlTagId) : ''; 466 const tagMismatch = urlTagSlug 467 ? urlTagSlug !== ssrSlug 468 : Boolean(legacySlug && legacySlug !== ssrSlug); 469 if (needsInitialFetch || tagMismatch || urlQ) { 470 clearGrid(); fetchPage(); 471 } else { 472 // continue with page 2 onward 473 S.state.page = 1; // next fetch increments 474 } 475 476 window.addEventListener('popstate', ()=>{ 477 syncFromUrl({ allowReplace: false }); 478 if (S.els.searchInput) {
479 S.els.searchInput.value = S.state.search || ''; 480 } 481 updateActiveChip(); 482 clearGrid(); updateTitle(); fetchPage(); 483 }); 484 }); 485})();
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.