Linguist Expand

Expands Github's Linguist language list on repositories to show every language instead of hiding the small percentage under "Other"

// ==UserScript==
// @name Linguist Expand
// @namespace https://davoleo.net
// @author Davoleo
// @homepage https://github.com/Davoleo/scripts/tree/master/linguist_expand
// @description Expands Github's Linguist language list on repositories to show every language instead of hiding the small percentage under "Other"
// @contributionURL https://davoleo.net/donate
// @match https://github.com/*
// @require https://unpkg.com/[email protected]/dist/js-yaml.min.js
// @resource languageColors https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml
// @grant GM_getResourceText
// @grant GM_log
// @grant GM_xmlhttpRequest
// @connect api.github.com
// @run-at document-idle
// @version 1.0.2
// @license MIT
// @noframes
// ==/UserScript==

//Loads languages.yml (from Github's linguist repo) the most updated, official and complete collection of github languages and their colors
//loaded into a JS object via jsyaml (a library to parse yaml inside of javascript)
const languages = jsyaml.load(GM_getResourceText('languageColors'));

//Contains information about languages and their percentages in the repository
let langPercentagesMap = {}
//Contains information about languages and their colors
let langColorsMap = {}

/**
 * Function to standardize and modernize GM_xmlhttpRequest to work with promises
 * @param {String} url of the endpoint
 * @param {Object} options Contains extra information about the request
 * @returns a promise with the requested content
 */
function request(url, options={}) {
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      url,
      method: options.method || "GET",
      headers: options.headers || {Accept: "application/json",
                                   "Content-Type": "application/json"},
      responseType: options.responseType || "json",
      data: options.body || options.data,
      onload: res => resolve(res.response),
      onerror: reject
    })
  })
}

/**
 * Retrieve information about the languages of a repository via the Github API
 * @param {String} user owner of the repository
 * @param {String} repo name
 * @returns the languages of the repository as a JS Object | null if the promise is rejected for any reason.
 */
async function retrieveLanguages(user, repo) {
    try {
        return await request(`https://api.github.com/repos/${user}/${repo}/languages`, {
            headers: {
                Accept: "application/vnd.github.v3+json"
            }
        });
    }
    catch(e) {
        return null;
    }
}

/**
 * Builds language bar segments assigning the correct colors and width depending on the language and it's frequency in the repository
 * @param {string} name of the language
 * @param {string} color of the language
 * @param {number} percentage of the language in the repository code
 * @returns a segment span of the language bar with the correct width and color
 */
function buildBarSegmentSpan(name, color, percentage) {
    const segment = document.createElement('span');
    segment.style.setProperty('background-color', color, 'important');
    segment.style.width = percentage + '%';
    //Removes any margin which would make the language bar otherwise inaccurate
    segment.style.setProperty('margin', '0', 'important');
    //Make sure there's at least 1px of width in the bar segment (fixes width of 0.0% segments)
    //TODO: investigate a better way to do this
    segment.style.paddingLeft = '1px';
    segment.setAttribute("itemprop", "keywords");
    segment.setAttribute("aria-label", name + ' ' + percentage);
    segment.setAttribute("data-view-component", "true");
    segment.setAttribute("class", "Progress-item color-bg-success-inverse lingustexpand");
    return segment;
}

/**
 * Builds a chip for each language containing
 * - The Color of the language in the bar
 * - The Name of the language
 * - The Percentage of the language in repository files
 * @param {String} owner of the repository
 * @param {String} repo name
 * @param {String} name of the language
 * @param {String} color of the language
 * @param {number} percentage percentage of the language in the repository code
 * @returns A chip components featured as legend for the language bar
 */
function buildLanguageChip(owner, repo, name, color, percentage) {
    const chip = document.createElement('li');
    chip.classList.add('d-inline');

    const chipLink = document.createElement('a');
    chipLink.classList.add('d-inline-flex', 'flex-items-center', 'flex-nowrap', 'Link--secondary', 'no-underline', 'text-small', 'mr-3');
    chipLink.href = `/${owner}/${repo}/search?l=${name}` //Chip link should bring you to the search query with the correct language in place

    //Parse SVG BALL directly injecting the correct color as in-line style
    const svgText = `
    <svg style="color:${color};" aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" 
            width="16" data-view-component="true" class="octicon octicon-dot-fill mr-2">
        <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8z"></path>
    </svg>
    `;
    const svgTMP = document.createElement('template');
    svgTMP.innerHTML = svgText;
    chipLink.append(svgTMP.content);
    //^ uses a template HTMLElement to parse HTML into its respective DOM elements

    //Adds language name to the chip
    const chipName = document.createElement('span');
    chipName.classList.add('color-fg-default', 'text-bold', 'mr-1');
    chipName.textContent = name;
    chipLink.append(chipName);

    //Adds Language percentage to the chip
    const chipValue = document.createElement('span');
    chipValue.textContent = percentage + '%';
    chipLink.append(chipValue);

    chip.append(chipLink);
    return chip;
}

/**
 * Builds the custom language stats section and returns it
 * @returns The full section with complete repository language stats
 */
function buildLanguagesSection(owner, repo) {

    const languageSection = document.createElement("div");
    languageSection.classList.add("mb-3", "mt-1");

    const bar = document.createElement('span');
    bar.classList.add("Progress", 'mb-2');
    bar.setAttribute("data-view-component", "true");
    Object.keys(langColorsMap).forEach((lang, i) => {
        const segment = buildBarSegmentSpan(lang, langColorsMap[lang], langPercentagesMap[lang]);
        //if (i !== 0) {
        //    segment.style.setProperty('margin-left', '1px');
        //}
        bar.appendChild(segment);
    });
    languageSection.append(bar);

    const languageUL = document.createElement('ul');
    Object.keys(langColorsMap).forEach((lang) => {
        const languageChip = buildLanguageChip(owner, repo, lang, langColorsMap[lang], langPercentagesMap[lang]);
        languageUL.append(languageChip);
    });
    languageSection.append(languageUL);

    return languageSection;
}

//MAIN ENTRY POINT
(() => {
    'use strict';

    //Selects the box element that contains files and folders on the repo page
    const mainContent = document.querySelector(".Box-sc-g0xbh4-0.yfPnm");
    if (!mainContent)
        throw Error("mainContent Hook Selector is dead!")

    //The original language bar in the sidebar
    const originalLangBar = document.querySelector("div.Layout-sidebar span.Progress");

    //array that is generated from the tab URL, it's structured this way: ["", "<repo_owner>", "<repo_name>"]
    const ownerRepo = window.location.pathname.split('/');

    //only works against github.com/ABC/DEF links
    if (ownerRepo.length === 3) {
        //retrieves necessary information about the repository's languages
        retrieveLanguages(ownerRepo[1], ownerRepo[2]).then((lang_vals) => {
            //assume request is successful if object is not null and it doesn't contain 'message' in its keys
            if (lang_vals !== null && !lang_vals.message) {
                //Sum of all language values
                const total = Object.values(lang_vals).reduce((prev, curr) => prev + curr);
                //for each language in the object
                Object.keys(lang_vals).forEach((lang) => {
                    //
                    langColorsMap[lang] = languages[lang].color;
                    langPercentagesMap[lang] = ((lang_vals[lang] / total) * 100).toFixed(1);
                });
            }
            else return; //Short Circuit

            //Build the new custom lang stats
            const languageSection = buildLanguagesSection(ownerRepo[1], ownerRepo[2]);
            mainContent.insertAdjacentElement('beforebegin', languageSection);
            //^ inserts our custom language stats before the box containing directories and files

            //GM_log(langColorsMap);
            //GM_log(langPercentagesMap);

            //Remove original Language Section (sidebar)
            originalLangBar.parentElement.parentElement.remove();
        });
    }
})();