PageSourceSearch

https://www.tellyawards.com/wp-content/mu-plugins/rm-winners/assets/rm-winners-router.js?ver=2026.09.13.02

js tellyawards.com collected 2026-10-02 03:14:11 UTC 19,654 bytes, 654 lines download raw bytes

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.