Speedrun.com JSON UI Viewer

Renders exported Speedrun.com JSON files as a readable UI with flags

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Speedrun.com JSON UI Viewer
// @namespace    local
// @version      1.1
// @description  Renders exported Speedrun.com JSON files as a readable UI with flags
// @author       local
// @license      MIT
// @match        file:///*speedrun*.json*
// @match        *://*/*speedrun*.json*
// @run-at       document-end
// @grant        none
// ==/UserScript==

/*
MIT License

Copyright (c) 2026

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files, to deal in the Software
without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
*/

(function () {
    'use strict';

    function readJsonFromPage() {
        const text =
            document.body?.innerText ||
            document.querySelector('pre')?.innerText ||
            '';

        try {
            const json = JSON.parse(text);
            return Array.isArray(json) ? json : null;
        } catch (e) {
            return null;
        }
    }

    function el(tag, className, text) {
        const node = document.createElement(tag);

        if (className) node.className = className;
        if (text !== undefined) node.textContent = text;

        return node;
    }

    function link(url, text, className) {
        if (!url) return el('span', className, text || '');

        const a = document.createElement('a');
        a.href = url;
        a.textContent = text || url;
        a.className = className || '';
        a.target = '_blank';
        a.rel = 'noopener noreferrer';

        return a;
    }

    function flag(url, name) {
        if (!url) return null;

        const img = document.createElement('img');

        img.src = url;
        img.alt = name || '';
        img.title = name || '';
        img.className = 'src-flag';

        return img;
    }

    function addStyles() {
        const style = document.createElement('style');

        style.textContent = `
            :root {
                color-scheme: dark;
                font-family: Inter, Arial, sans-serif;
                background: #111318;
                color: #e8e8e8;
            }

            body {
                margin: 0;
                background: #111318;
                color: #e8e8e8;
            }

            .src-viewer {
                max-width: 1200px;
                margin: 0 auto;
                padding: 24px;
            }

            .src-title {
                display: flex;
                justify-content: space-between;
                align-items: center;
                gap: 16px;
                margin-bottom: 20px;
            }

            .src-title h1 {
                margin: 0;
                font-size: 24px;
            }

            .src-count {
                color: #aaa;
                font-size: 14px;
            }

            .src-table {
                width: 100%;
                border-collapse: collapse;
                overflow: hidden;
                border-radius: 10px;
                background: #191c22;
                box-shadow: 0 0 0 1px #2a2e38;
            }

            .src-table th,
            .src-table td {
                padding: 10px 12px;
                border-bottom: 1px solid #2a2e38;
                text-align: left;
                vertical-align: top;
                font-size: 14px;
            }

            .src-table th {
                background: #222631;
                color: #f1f1f1;
                font-weight: 700;
                position: sticky;
                top: 0;
                z-index: 2;
            }

            .src-table tr:hover td {
                background: #20242c;
            }

            .src-rank {
                width: 56px;
                text-align: center;
                color: #ffd36a;
                font-weight: 700;
            }

            .src-time {
                color: #7dd3fc;
                font-weight: 700;
                white-space: nowrap;
            }

            .src-muted {
                color: #aaa;
            }

            .src-link {
                color: #8ab4ff;
                text-decoration: none;
            }

            .src-link:hover {
                text-decoration: underline;
            }

            .src-inline {
                display: inline-flex;
                align-items: center;
                gap: 6px;
            }

            .src-flag {
                height: 12px;
                width: auto;
                border-radius: 2px;
                flex: none;
            }

            .src-level-card {
                margin-bottom: 14px;
                padding: 16px;
                border-radius: 10px;
                background: #191c22;
                box-shadow: 0 0 0 1px #2a2e38;
            }

            .src-level-header {
                margin-bottom: 12px;
                font-size: 18px;
                font-weight: 700;
            }

            .src-places {
                display: grid;
                grid-template-columns: repeat(3, minmax(0, 1fr));
                gap: 12px;
            }

            .src-place {
                padding: 12px;
                border-radius: 8px;
                background: #222631;
            }

            .src-place-title {
                margin-bottom: 6px;
                color: #ffd36a;
                font-weight: 700;
            }

            .src-vars {
                display: flex;
                flex-wrap: wrap;
                gap: 6px;
            }

            .src-var {
                padding: 3px 7px;
                border-radius: 999px;
                background: #2a2e38;
                color: #ddd;
                font-size: 12px;
                white-space: nowrap;
            }

            @media (max-width: 800px) {
                .src-viewer {
                    padding: 12px;
                }

                .src-table {
                    display: block;
                    overflow-x: auto;
                }

                .src-places {
                    grid-template-columns: 1fr;
                }
            }
        `;

        document.head.appendChild(style);
    }

    function formatVariableName(key) {
        return key
            .replace(/_/g, ' ')
            .replace(/\b\w/g, char => char.toUpperCase());
    }

    function renderVariables(item) {
        const box = el('div', 'src-vars');
        const variables = item.variables || {};

        Object.keys(variables).forEach(key => {
            box.appendChild(
                el('span', 'src-var', `${formatVariableName(key)}: ${variables[key]}`)
            );
        });

        return box;
    }

    function inlineWithFlag(flagUrl, flagName, child) {
        const box = el('span', 'src-inline');
        const img = flag(flagUrl, flagName);

        if (img) box.appendChild(img);
        box.appendChild(child);

        return box;
    }

    function makePlayerCell(item) {
        const td = el('td');
        const player = link(item.user_link, item.player || '', 'src-link');

        td.appendChild(
            inlineWithFlag(item.user_flag_url, item.user_flag_name, player)
        );

        return td;
    }

    function makePlatformCell(item) {
        const td = el('td');
        const text = el('span', '', item.platform || '');

        td.appendChild(
            inlineWithFlag(item.platform_flag_url, item.platform_flag_name, text)
        );

        return td;
    }

    function makeLinkedCell(url, text, className) {
        const td = el('td');

        if (url) {
            td.appendChild(link(url, text, className || 'src-link'));
        } else {
            td.appendChild(el('span', className || '', text));
        }

        return td;
    }

    function renderNormalLeaderboard(data) {
        const table = el('table', 'src-table');

        const thead = el('thead');
        const headerRow = el('tr');

        ['#', 'Player', 'Time', 'Date', 'Platform', 'Variables', 'Run'].forEach(name => {
            headerRow.appendChild(el('th', '', name));
        });

        thead.appendChild(headerRow);
        table.appendChild(thead);

        const tbody = el('tbody');

        data.forEach(item => {
            const tr = el('tr');

            tr.appendChild(el('td', 'src-rank', item.rank || ''));
            tr.appendChild(makePlayerCell(item));
            tr.appendChild(makeLinkedCell(item.run_link, item.time || '', 'src-time'));
            tr.appendChild(el('td', 'src-muted', item.date || ''));
            tr.appendChild(makePlatformCell(item));

            const varsCell = el('td');
            varsCell.appendChild(renderVariables(item));
            tr.appendChild(varsCell);

            const runCell = el('td');
            runCell.appendChild(link(item.run_link, item.run_link ? 'Open run' : '', 'src-link'));
            tr.appendChild(runCell);

            tbody.appendChild(tr);
        });

        table.appendChild(tbody);
        return table;
    }

    function renderPlace(placeData, label) {
        const box = el('div', 'src-place');

        box.appendChild(el('div', 'src-place-title', label));

        const player = el('div');
        player.appendChild(
            inlineWithFlag(
                placeData.user_flag_url,
                placeData.user_flag_name,
                link(placeData.user_link, placeData.player || 'Unknown', 'src-link')
            )
        );
        box.appendChild(player);

        box.appendChild(el('div', 'src-time', placeData.time || ''));

        if (placeData.run_link) {
            const run = el('div');
            run.appendChild(link(placeData.run_link, 'Open run', 'src-link'));
            box.appendChild(run);
        }

        return box;
    }

    function renderLevelTop(data) {
        const wrapper = el('div');

        data.forEach(item => {
            const card = el('div', 'src-level-card');

            const header = el('div', 'src-level-header');
            header.appendChild(link(item.level_link, item.level || 'Unknown level', 'src-link'));
            card.appendChild(header);

            const places = el('div', 'src-places');

            places.appendChild(renderPlace(item.first || {}, '1st place'));
            places.appendChild(renderPlace(item.second || {}, '2nd place'));
            places.appendChild(renderPlace(item.third || {}, '3rd place'));

            card.appendChild(places);
            wrapper.appendChild(card);
        });

        return wrapper;
    }

    function render(data) {
        document.body.innerHTML = '';
        document.title = 'Speedrun.com JSON Viewer';

        addStyles();

        const app = el('div', 'src-viewer');

        const title = el('div', 'src-title');
        title.appendChild(el('h1', '', 'Speedrun.com JSON Viewer'));
        title.appendChild(el('div', 'src-count', `${data.length} rows`));

        app.appendChild(title);

        if (data[0]?.type === 'level_top') {
            app.appendChild(renderLevelTop(data));
        } else {
            app.appendChild(renderNormalLeaderboard(data));
        }

        document.body.appendChild(app);
    }

    const data = readJsonFromPage();

    if (!data) return;

    render(data);
})();