mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-12 04:53:52 -06:00
166 lines
6.9 KiB
TypeScript
166 lines
6.9 KiB
TypeScript
// What the site counts, as wiki/privacy.md states it. No cookie, no storage
|
|
// on the device, no identifier: one request per page or action to
|
|
// GoatCounter, which keeps totals. Nothing is sent when the browser asks
|
|
// not to be tracked (Do Not Track, Global Privacy Control), from a local
|
|
// build, or from a page not served at the site's address.
|
|
//
|
|
// Sent: the page path (never its query or #anchor), the origin of the site
|
|
// the reader came from (scheme and host, no path), and the name of an
|
|
// action. An action is a click on
|
|
// a copy button, a pack or file download, a link that leaves the site, a
|
|
// site search that found nothing (its words only in the shapes below),
|
|
// and the answer to "Did this page help?".
|
|
|
|
const ENDPOINT = 'https://retrobios.goatcounter.com/count';
|
|
const HOST = 'abdess.github.io';
|
|
const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
|
|
|
const declined = (): boolean => {
|
|
const nav = navigator as Navigator & { globalPrivacyControl?: boolean };
|
|
return nav.doNotTrack === '1' || nav.globalPrivacyControl === true;
|
|
};
|
|
const enabled = location.hostname === HOST && !declined();
|
|
|
|
// The path under the site, "/emulators/mgba/": the base names every page.
|
|
const pagePath = (): string => {
|
|
const path = location.pathname;
|
|
return path.startsWith(BASE) ? `/${path.slice(BASE.length)}` : path;
|
|
};
|
|
|
|
const send = (params: Record<string, string>): void => {
|
|
if (!enabled) return;
|
|
const url = `${ENDPOINT}?${new URLSearchParams({ ...params, rnd: Math.random().toString(36).slice(2) })}`;
|
|
if (!navigator.sendBeacon?.(url)) new Image().src = url;
|
|
};
|
|
|
|
// An action, filed under its name with the page it happened on as title.
|
|
export const countEvent = (name: string): void => {
|
|
send({ p: name.slice(0, 120), t: pagePath(), e: 'true' });
|
|
};
|
|
|
|
// The site the reader came from, by its origin alone: a path can carry a
|
|
// search or a private thread.
|
|
const referrer = (): string => {
|
|
try {
|
|
const from = new URL(document.referrer);
|
|
return from.hostname && from.hostname !== location.hostname ? from.origin : '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const countPage = (): void => {
|
|
const params: Record<string, string> = { p: pagePath() };
|
|
const from = referrer();
|
|
if (from) params.r = from;
|
|
// GitHub Pages serves 404.html at the address asked for: the path says
|
|
// which link is broken, the title that it was one.
|
|
if (document.title.startsWith('Page not found')) params.t = '404';
|
|
send(params);
|
|
};
|
|
|
|
// Clicks
|
|
|
|
const slug = (text: string): string => text.trim().toLowerCase().replace(/[^a-z0-9.+-]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
|
|
|
|
const linkAction = (link: HTMLAnchorElement): string | undefined => {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(link.href, location.href);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
const release = /\/releases\/download\/[^/]+\/([^/?#]+)$/.exec(url.pathname);
|
|
if (url.hostname === 'github.com' && release) return `download/${decodeURIComponent(release[1])}`;
|
|
if (url.hostname === 'raw.githubusercontent.com') {
|
|
return `file/${decodeURIComponent(url.pathname.split('/').pop() ?? '')}`;
|
|
}
|
|
if (url.hostname === location.hostname) {
|
|
const get = new RegExp(`^${BASE}get/([^/]+)/`).exec(url.pathname);
|
|
return get ? `get/${get[1]}` : undefined;
|
|
}
|
|
return url.protocol.startsWith('http') ? `out/${url.hostname}` : undefined;
|
|
};
|
|
|
|
const onClick = (event: MouseEvent): void => {
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
if (!target) return;
|
|
const copy = target.closest('.expressive-code .copy button, [data-copy]');
|
|
if (copy) {
|
|
const frame = copy.closest('.frame');
|
|
const title = frame?.querySelector('.title')?.textContent ?? '';
|
|
countEvent(`copy/${slug(title) || (copy.matches('[data-copy]') ? 'picker' : 'code')}`);
|
|
return;
|
|
}
|
|
const help = target.closest<HTMLButtonElement>('[data-rb-helpful]');
|
|
if (help) {
|
|
countEvent(`helpful/${help.dataset.rbHelpful}`);
|
|
return;
|
|
}
|
|
const link = target.closest<HTMLAnchorElement>('a[href]');
|
|
const action = link && linkAction(link);
|
|
if (action) countEvent(action);
|
|
};
|
|
|
|
// Search
|
|
|
|
// A search that found nothing names a missing page. Its words are sent
|
|
// only in three shapes: a hash with at least one letter, a file name
|
|
// ("scph5501.bin"), or one word that starts with a letter and holds at most
|
|
// four digits ("scph5501", "ps3updat"). Any other search is counted with no
|
|
// words, as "other". A number alone, a phone number among them, is never
|
|
// sent. A nickname of the third shape ("dupont1985") is, as wiki/privacy.md
|
|
// says: nothing tells it from a file name.
|
|
// An 8-digit phone number is valid hex: a hash needs a letter, and a CRC32
|
|
// written in digits only is counted as "other".
|
|
const isHash = (query: string): boolean =>
|
|
/^(?:[0-9a-f]{8}|[0-9a-f]{32}|[0-9a-f]{40}|[0-9a-f]{64})$/.test(query) && /[a-f]/.test(query);
|
|
// The extensions of the files the collection holds: "john.smith" is no file.
|
|
const isFileName = (query: string): boolean =>
|
|
/^[\w.+-]{1,60}\.(?:bin|rom|zip|7z|pup|keys|img|dat|bios|ic\d+|u\d+|sys|nvm|mec|rom1|j64|pce|col|sms|gb|gbc|gba|nds|cfg|chd|iso|txt|fw|xml|yaml|pak|pk3|grp)$/.test(query);
|
|
const isFileStem = (query: string): boolean =>
|
|
/^[a-z][a-z0-9_-]{1,30}$/.test(query) && /\d/.test(query) && (query.match(/\d/g) ?? []).length <= 4;
|
|
const searchWords = (query: string): string =>
|
|
isHash(query) || isFileName(query) || isFileStem(query) ? query : 'other';
|
|
|
|
const watchSearch = (): void => {
|
|
const root = document.getElementById('starlight__search');
|
|
if (!root) return;
|
|
const sent = new Set<string>();
|
|
let timer: number | undefined;
|
|
const settle = (): void => {
|
|
const input = root.querySelector<HTMLInputElement>('.pagefind-ui__search-input');
|
|
const query = (input?.value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
// Pagefind's English message once a search has ended with nothing;
|
|
// while it runs the message reads "Searching for ...".
|
|
const message = root.querySelector('.pagefind-ui__message')?.textContent ?? '';
|
|
if (!query || !message.startsWith('No results for') || sent.has(query)) return;
|
|
sent.add(query);
|
|
countEvent(`search-none/${searchWords(query)}`);
|
|
};
|
|
new MutationObserver(() => {
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(settle, 1500);
|
|
}).observe(root, { childList: true, subtree: true, characterData: true });
|
|
};
|
|
|
|
// "Did this page help?": the buttons answer once, then say thanks.
|
|
const wireHelpful = (): void => {
|
|
// Shown only where an answer is sent: a box that counts nothing would ask for nothing.
|
|
if (!enabled) return;
|
|
for (const box of document.querySelectorAll<HTMLElement>('[data-rb-helpful-box]')) {
|
|
box.hidden = false;
|
|
box.addEventListener('click', (event) => {
|
|
if (!(event.target instanceof Element) || !event.target.closest('[data-rb-helpful]')) return;
|
|
for (const button of box.querySelectorAll('button')) button.remove();
|
|
const thanks = box.querySelector<HTMLElement>('[data-rb-helpful-thanks]');
|
|
if (thanks) thanks.hidden = false;
|
|
});
|
|
}
|
|
};
|
|
|
|
countPage();
|
|
document.addEventListener('click', onClick, { capture: true });
|
|
watchSearch();
|
|
wireHelpful();
|