1/** 2 * The URL contract, browser side. 3 * 4 * Mirrors inc/class-rm-winners-router.php. Both read the same configuration, so the two 5 * cannot drift apart in what they *decide*; this file has to match what the PHP *does*. 6 * `wp rm-winners parity` asserts that on every URL form the sites actually emit. 7 * 8 * Build: 2026.09.12.01 9 */ 10 11/* global rmWinners */ 12(function (window) { 13 'use strict'; 14 15 var config = window.rmWinners || {}; 16 17 /** 18 * Slugify a label. 19 * 20 * Character-for-character identical to rm_winners_slugify() in PHP, and to the copy the 21 * themes carried. Not a general-purpose slugifier: these slugs are embedded in live URLs, 22 * so its exact quirks â "/" becoming "-", accents folding to ASCII â are the contract. 23 * 24 * @param {string} str Label to slugify. 25 * @return {string} 26 */ 27 function slugify(str) { 28 str = String(str === null || str === undefined ? '' : str).replace(/^\s+|\s+$/g, ''); 29 str = str.toLowerCase(); 30 31 var from = 'ÃÃÃÃÃà ÄÃÄÄÃÄÃÃÃẼÄÈÃÃÃÃÅÃÃÃÃÃÃÃÅÅŠŤÃÅ®ÃÃÃߎáäâà ãåÄçÄÄéÄëèêẽÄÈÃìîïÅñóöòôõøðÅÅšťúůüùûýÿžþÃÄÄÃÃa·/_,:;'; 32 var to = 'AAAAAACCCDEEEEEEEEIIIINNOOOOOORRSTUUUUUYYZaaaaaacccdeeeeeeeeiiiinnooooooorrstuuuuuyyzbBDdBAa------'; 33 34 for (var i = 0, l = from.length; i < l; i++) { 35 str = str.split(from.charAt(i)).join(to.charAt(i)); 36 } 37 38 return str 39 .replace(/[^a-z0-9 -]/g, '') 40 .replace(/\s+/g, '-') 41 .replace(/-+/g, '-'); 42 } 43 44 /** 45 * The canonical filter state. 46 * 47 * @return {Object} 48 */ 49 function defaultState() { 50 return { 51 year: '', 52 // Comma-joined year labels, for galleries whose year filter is multi-select. 53 years: '', 54 event_id: 0, 55 media_type: '', 56 media_slug: '', 57 category_type: '', 58 category_type_slug: '', 59 category: '', 60 category_slug: '', 61 title_slug: '', 62 // signal's detail URL carries the entrant name and a background-rotation flag 63 // as path segments. Neither filters anything, but both are in live URLs. 64 organization_slug: '', 65 alt_flag: '', 66 award: '', 67 sort: '', 68 page: 1, 69 search: '', 70 entry_id: '' 71 }; 72 } 73 74 /** 75 * Merge a partial state over the defaults. 76 * 77 * @param {Object} partial Values to apply. 78 * @return {Object} 79 */ 80 function withDefaults(partial) { 81 var state = defaultState(); 82 var key; 83 84 for (key in partial || {}) { 85 if (Object.prototype.hasOwnProperty.call(partial, key) && 86 Object.prototype.hasOwnProperty.call(state, key) && 87 partial[key] !== null && partial[key] !== undefined) { 88 state[key] = partial[key]; 89 } 90 } 91 92 return state; 93 } 94 95 function scheme(key, fallback) { 96 var s = config.scheme || {}; 97 return Object.prototype.hasOwnProperty.call(s, key) ? s[key] : fallback; 98 } 99 100 function paramName(role) { 101 var params = scheme('params', {}); 102 return Object.prototype.hasOwnProperty.call(params, role) ? params[role] : role; 103 } 104 105 /* --------------------------------------------------------------------- 106 * Parsing 107 * ------------------------------------------------------------------ */ 108 109 /** 110 * Read one query parameter. 111 * 112 * Replaces the getParameterByName copies in the templates, which built a RegExp from an 113 * uninterpolated name and returned the literal string "null" for a missing value. 114 * 115 * @param {string} name Parameter name. 116 * @param {string} search Query string including the leading '?'. 117 * @return {string} 118 */ 119 function getParam(name, search) { 120 var params = new URLSearchParams(search === undefined ? window.location.search : search); 121 var value = params.get(name); 122 123 return value === null ? '' : value; 124 } 125 126 /** 127 * Parse a URL into canonical state. 128 * 129 * @param {string} [url] Defaults to the current location. 130 * @return {Object} 131 */ 132 function parse(url) { 133 var target = url ? new URL(url, config.homeUrl || window.location.origin) : window.location; 134 var state = defaultState(); 135 136 switch (config.urlScheme) { 137 case 'query': 138 parseQuery(state, target.search); 139 break; 140 case 'path': 141 parsePath(state, target.pathname, target.search); 142 break; 143 case 'hash': 144 parseHash(state, target.hash); 145 break; 146 } 147 148 return state; 149 } 150 151 function parseQuery(state, search) { 152 state.event_id = parseInt(getParam(paramName('event'), search), 10) || 0; 153 state.award = getParam(paramName('award'), search); 154 state.media_type = getParam(paramName('type'), search); 155 state.category = getParam(paramName('category'), search); 156 state.category_type = getParam(paramName('category_type'), search); 157 state.year = getParam(paramName('year'), search); 158 state.sort = getParam(paramName('sort'), search); 159 state.search = getParam(paramName('search'), search); 160 state.entry_id = getParam(paramName('id'), search); 161 state.years = getParam(paramName('years'), search); 162 163 var categorySlug = getParam(paramName('category_slug'), search); 164 if (categorySlug !== '') { 165 state.category_slug = categorySlug; 166 } 167 168 var level = getParam(paramName('level'), search); 169 if (level !== '') { 170 state.award = level; 171 } 172 173 var page = parseInt(getParam(paramName('pg'), search), 10); 174 state.page = page > 0 ? page : 1; 175 176 state.award = normalizeAward(state.award); 177 // Both keys â see the note in rm_winners_parse_request(). 178 state.category = normalizeCategory(state.category); 179 state.category_type = normalizeCategory(state.category_type); 180 181 return state; 182 } 183 184 /** 185 * Resolve the "all awards" sentinel to no filter at all. 186 * 187 * These galleries write &award=99 to mean "every level", and the API takes that literally: 188 * AwardOptionID 99 matches no award, so the query comes back empty. Parsing a URL has 189 * always collapsed it, but the select hands its raw option value to the state directly, so
190 * the rule has to live somewhere both can reach rather than inline in the parser. 191 * 192 * @param {string} value Award as it arrives from a URL or a <select>. 193 * @return {string} The award, or '' when it means "all". 194 */ 195 /** 196 * Resolve the "all categories" sentinel to no filter at all. 197 * 198 * The mirror of rm_winners_normalize_category(); see that function for the measurements. 199 * The short version: every Anthem cause link carries `-1`, and the API returns nothing for 200 * it rather than everything. 201 * 202 * @param {string} value Category as it arrives from a URL. 203 * @return {string} The category, or '' when it means "all". 204 */ 205 function normalizeCategory(value) { 206 var category = value === null || value === undefined ? '' : String(value); 207 208 if (category === '') { 209 return ''; 210 } 211 212 var accepted = scheme('category_all_input', []) || []; 213 214 for (var i = 0; i < accepted.length; i++) { 215 if (String(accepted[i]) !== '' && 216 category.toLowerCase() === String(accepted[i]).toLowerCase()) { 217 return ''; 218 } 219 } 220 221 return category; 222 } 223 224 function normalizeAward(value) { 225 var award = value === null || value === undefined ? '' : String(value); 226 227 if (award === '') { 228 return ''; 229 } 230 231 // More than one value can mean "all" inbound; see the PHP mirror. 232 var accepted = (scheme('award_all_input', []) || []).concat([scheme('award_all', '')]); 233 234 for (var i = 0; i < accepted.length; i++) { 235 if (String(accepted[i]) !== '' && award === String(accepted[i])) { 236 return ''; 237 } 238 } 239 240 return award; 241 } 242 243 /** 244 * Resolve the "all media types" sentinel to no filter. 245 * 246 * The control offers "All Categories" as a real option with a real value â 0 on the query 247 * galleries â and choosing it has to mean *no* media type, not media type zero. The URL 248 * builder already knew that and dropped it; the request builder did not, so picking "All" 249 * after a search sent MediaTypeOptionIDs: [0], which matches nothing. The URL came out clean 250 * either way, so the bar and the address bar both looked correct over an empty grid. 251 * 252 * Defaulting to '0' rather than reading it from the seed keeps every site exactly where it 253 * already was: that value was hard-coded in the builder for all of them. 254 * 255 * @param {string|number} value 256 * @return {string} 257 */ 258 function normalizeMediaType(value) { 259 var media = value === null || value === undefined ? '' : String(value); 260 261 if (media === '') { 262 return ''; 263 } 264 265 var accepted = (scheme('media_all_input', []) || []).concat([scheme('media_all', '0')]); 266 267 for (var i = 0; i < accepted.length; i++) { 268 if (String(accepted[i]) !== '' && media === String(accepted[i])) { 269 return ''; 270 } 271 } 272 273 return media; 274 } 275 276 function parsePath(state, pathname, search) { 277 var base = String(scheme('base', config.galleryPath || '/')).replace(/^\/+|\/+$/g, ''); 278 var trimmed = String(pathname).replace(/^\/+|\/+$/g, ''); 279 280 if (base && trimmed.indexOf(base) === 0) { 281 trimmed = trimmed.slice(base.length).replace(/^\/+/, ''); 282 } 283 284 var parts = trimmed === '' ? [] : trimmed.split('/'); 285 // The longer layout is the detail one; pick it only when the extra segment is present. 286 var segments = parts.length > (scheme('segments', []) || []).length 287 ? scheme('detail_segments', []) 288 : scheme('segments', []); 289 var empty = scheme('empty_segment', 'all'); 290 291 (segments || []).forEach(function (key, index) { 292 var value = parts[index]; 293 294 if (value === undefined || value === empty) { 295 return; 296 } 297 298 state[key] = decodeURIComponent(value); 299 }); 300 301 state.search = getParam('search', search); 302 state.award = getParam('award', search); 303 304 // A modal gallery on a path scheme names its winner beside the view; see rm_winners_parse_path(). 305 var modal = String(scheme('modal_param', '') || ''); 306 307 if (modal !== '' && String(state.entry_id || '') === '') { 308 var named = getParam(modal, search); 309 310 if (named !== '' && named !== '0') { 311 state.entry_id = named; 312 } 313 } 314 315 return state; 316 } 317 318 function parseHash(state, hash) { 319 var raw = String(hash || '').replace(/^#/, ''); 320 var prefix = scheme('detail_prefix', ''); 321 322 if (prefix && raw.indexOf(prefix) === 0) {
323 raw = raw.slice(prefix.length); 324 } 325 326 if (raw === '') { 327 return state; 328 } 329 330 var parts = raw.split('/'); 331 var variants = scheme('variants', {}); 332 var layout = variants[parts.length]; 333 334 if (!layout) { 335 return state; 336 } 337 338 layout.forEach(function (key, index) { 339 if (parts[index] !== undefined) { 340 state[key] = decodeURIComponent(parts[index]); 341 } 342 }); 343 344 return state; 345 } 346 347 /* --------------------------------------------------------------------- 348 * Building 349 * ------------------------------------------------------------------ */ 350 351 /** 352 * Build this site's URL for a filter state. 353 * 354 * @param {Object} partial Partial state. 355 * @return {string} 356 */ 357 function build(partial) { 358 var state = withDefaults(partial); 359 360 /* 361 * A site can express filter state one way and a single winner another. Anthem and 362 * signal do exactly that: filters ride in a query string, a winner gets its own 363 * path, so the winner page is a real crawlable URL rather than a fragment the 364 * server never sees. 365 */ 366 if (state.entry_id !== '' && config.detailScheme === 'slug-id') { 367 return buildDetail(state); 368 } 369 370 switch (config.urlScheme) { 371 case 'query': 372 return buildQuery(state); 373 case 'path': 374 return buildPath(state); 375 case 'hash': 376 return buildHash(state); 377 default: 378 return ''; 379 } 380 } 381 382 /** 383 * Build the query-string format. 384 * 385 * Emits only the parameters the site's `emit` list names, in that order. The three 386 * query-scheme galleries read a `category` parameter but never write one, so emitting 387 * it would change every live URL. 388 * 389 * @param {Object} state Canonical state. 390 * @return {string} 391 */ 392 function buildQuery(state) { 393 var emit = scheme('emit', ['event']); 394 var parts = []; 395 396 emit.forEach(function (role) { 397 var pair = queryPair(role, state); 398 399 if (pair !== null) { 400 parts.push(pair); 401 } 402 }); 403 404 // An unfiltered view on a site whose emit list is all-optional would otherwise end 405 // in a bare "?". 406 if (!parts.length) { 407 return config.galleryPath || '/'; 408 } 409 410 return (config.galleryPath || '/') + '?' + parts.join('&'); 411 } 412 413 /** 414 * One key=value pair, or null when the role is omitted. 415 * 416 * @param {string} role Canonical role. 417 * @param {Object} state Canonical state. 418 * @return {?string} 419 */ 420 function queryPair(role, state) { 421 var key = paramName(role); 422 423 switch (role) { 424 case 'event': 425 return key + '=' + encodeURIComponent(String(state.event_id)); 426 427 case 'award': 428 var awardAll = scheme('award_all', ''); 429 430 if (state.award !== '') { 431 return key + '=' + encodeURIComponent(String(state.award)); 432 } 433 434 return awardAll !== '' ? key + '=' + encodeURIComponent(String(awardAll)) : null; 435 436 case 'type': 437 var media = normalizeMediaType(state.media_type); 438 439 return media !== '' ? key + '=' + encodeURIComponent(media) : null; 440 441 case 'category': 442 return state.category !== '' ? key + '=' + encodeURIComponent(String(state.category)) : null; 443 444 case 'category_type': 445 return state.category_type !== '' ? key + '=' + encodeURIComponent(String(state.category_type)) : null; 446 447 // year and sort exist for the two sites migrating off a hash, whose fragments 448 // carried a display year rather than an event id. 449 case 'year': 450 return state.year !== '' ? key + '=' + encodeURIComponent(String(state.year)) : null; 451 452 case 'sort': 453 return state.sort !== '' ? key + '=' + encodeURIComponent(String(state.sort)) : null; 454 455 case 'years': 456 // Commas stay literal; the theme's own builder does not encode them. 457 return state.years !== '' 458 ? key + '=' + encodeURIComponent(String(state.years)).replace(/%2C/g, ',') 459 : null; 460 461 // A category and an award level expressed as display slugs. Both drop out on 462 // "all", which is how the theme signals "no filter". 463 case 'category_slug': 464 return (state.category_slug !== '' && state.category_slug !== 'all') 465 ? key + '=' + encodeURIComponent(String(state.category_slug)) 466 : null; 467 468 case 'level': 469 return (state.award !== '' && state.award !== 'all') 470 ? key + '=' + encodeURIComponent(String(state.award)) 471 : null; 472 473 case 'pg': 474 return parseInt(state.page, 10) > 1 475 ? key + '=' + encodeURIComponent(String(parseInt(state.page, 10))) 476 : null; 477 478 case 'search': 479 return state.search !== '' ? key + '=' + encodeURIComponent(String(state.search)) : null; 480 481 case 'id': 482 return state.entry_id !== '' ? key + '=' + encodeURIComponent(String(state.entry_id)) : null; 483 } 484 485 return null; 486 } 487 488 /** 489 * Build a `<slug>-<id>` detail URL. 490 * 491 * The slug is decorative; the trailing numeric id is what resolves the entry, so a
492 * retitled winner keeps working from an old link. Mirrors rm_winners_build_detail_url(). 493 * 494 * @param {Object} state Canonical state; entry_id is required. 495 * @return {string} 496 */ 497 function buildDetail(state) { 498 var slug = [state.organization_slug, state.title_slug] 499 .filter(Boolean) 500 .join('-') 501 .replace(/^-+|-+$/g, ''); 502 503 if (slug.length > 80) { 504 slug = slug.slice(0, 80).replace(/-+$/, ''); 505 } 506 507 var segment = slug !== '' ? slug + '-' + state.entry_id : String(state.entry_id); 508 var base = config.detailBase || config.galleryPath || '/'; 509 510 return absolute(base).replace(/\/+$/, '') + '/' + encodeURIComponent(segment) + '/'; 511 } 512 513 function buildPath(state) { 514 var modal = String(scheme('modal_param', '') || ''); 515 516 // A winner on a modal gallery is its view's URL with the winner named in the query string. 517 if (modal !== '' && String(state.entry_id) !== '') { 518 // A view naming nothing is the gallery itself, as rm_winners_build_path_url() has it. 519 var placeholder = scheme('empty_segment', 'all'); 520 var named = (scheme('segments', []) || []).some(function (key) { 521 var value = state[key] === undefined || state[key] === null ? '' : String(state[key]); 522 523 return value !== '' && encodeURIComponent(value) !== placeholder; 524 }); 525 var viewUrl = named ? buildPath(Object.assign({}, state, { entry_id: '' })) : (config.galleryPath || '/'); 526 527 return viewUrl + (viewUrl.indexOf('?') === -1 ? '?' : '&') + encodeURIComponent(modal) + '=' + 528 encodeURIComponent(String(state.entry_id)); 529 } 530 531 var detail = state.entry_id !== ''; 532 var segments = detail ? scheme('detail_segments', []) : scheme('segments', []); 533 534 if (!segments || !segments.length) { 535 return config.galleryPath || '/'; 536 } 537 538 var empty = scheme('empty_segment', 'all'); 539 var path = segments.map(function (key) { 540 var value = state[key] === undefined || state[key] === '' ? '' : String(state[key]); 541 return value === '' ? empty : encodeURIComponent(value); 542 }); 543 544 var base = String(scheme('base', config.galleryPath || '/')); 545 var url = absolute(base).replace(/\/+$/, '') + '/' + path.join('/'); 546 547 if (scheme('trailing_slash', true)) { 548 url += '/'; 549 } 550 551 // Only some path sites carry filters in a query string; Telly writes bare 552 // segments, so an unconditional query here would change its URLs. 553 var carry = scheme('query_params', []); 554 var values = { search: state.search, award: state.award }; 555 var query = []; 556 557 carry.forEach(function (role) { 558 if (values[role] !== undefined && values[role] !== '') { 559 query.push(role + '=' + encodeURIComponent(String(values[role]))); 560 } 561 }); 562 563 if (query.length) { 564 url += '?' + query.join('&'); 565 566 // See the note on the PHP side: one site's builder leaves a trailing ampersand 567 // after a search with no award, and the parity harness holds us to it. 568 if (scheme('trailing_amp_after_search', false) && state.search !== '' && state.award === '') { 569 url += '&'; 570 } 571 } 572 573 return url; 574 } 575 576 function buildHash(state) { 577 var variants = scheme('variants', {}); 578 var detail = state.entry_id !== ''; 579 var layout = null; 580 var chosen = null; 581 var size; 582 583 for (size in variants) { 584 if (!Object.prototype.hasOwnProperty.call(variants, size)) { 585 continue; 586 } 587 588 var hasEntry = variants[size].indexOf('entry_id') !== -1; 589 590 if (hasEntry === detail && (chosen === null || parseInt(size, 10) > chosen)) { 591 chosen = parseInt(size, 10); 592 layout = variants[size]; 593 } 594 } 595 596 if (!layout) { 597 return config.galleryPath || '/'; 598 } 599 600 var defaults = scheme('segment_defaults', {}); 601 var required = scheme('required', []); 602 var incomplete = false; 603 604 var path = layout.map(function (key) { 605 var value = state[key] === undefined || state[key] === '' ? '' : String(state[key]); 606 607 if (value === '' && Object.prototype.hasOwnProperty.call(defaults, key)) { 608 value = String(defaults[key]); 609 } 610 611 // The themes only write a fragment once they have the values for it; a 612 // half-empty one like "#/all//-1" is a URL these sites never produce. 613 if (value === '' && required.indexOf(key) !== -1) { 614 incomplete = true; 615 } 616 617 return value; 618 }); 619 620 var base = detail 621 ? scheme('detail_base', config.galleryPath || '/') 622 : scheme('gallery_base', config.galleryPath || '/'); 623 if (incomplete) { 624 return absolute(base); 625 } 626
627 var prefix = detail ? scheme('detail_prefix', '') : scheme('gallery_prefix', ''); 628 629 return absolute(base) + '#' + prefix + path.join('/'); 630 } 631 632 /** 633 * Resolve a site-relative path against the site's home URL, matching home_url() in PHP. 634 * 635 * @param {string} path Site-root-relative path. 636 * @return {string} 637 */ 638 function absolute(path) { 639 var home = String(config.homeUrl || window.location.origin).replace(/\/+$/, ''); 640 return home + (String(path).charAt(0) === '/' ? path : '/' + path); 641 } 642 643 window.rmWinnersRouter = { 644 slugify: slugify, 645 getParam: getParam, 646 defaultState: defaultState, 647 withDefaults: withDefaults, 648 parse: parse, 649 build: build, 650 normalizeAward: normalizeAward, 651 normalizeMediaType: normalizeMediaType, 652 normalizeCategory: normalizeCategory 653 }; 654}(window));
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.