Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ A Chrome extension that brings the [Spice programming language](https://www.spic
- Markdown code blocks marked as ` ```spice ` in READMEs, issues, comments, etc.
- **Language statistics**: Spice is added to the language bar in the sidebar of repositories. Repositories without
any language known to GitHub (e.g. pure Spice repositories) get a new "Languages" section.
- **Primary language in repository lists**: if Spice makes up the majority of a repository's code, it is shown as the
language in lists of repositories (profiles, organizations, search results) instead of GitHub's own guess. Every
listed repository costs API requests, so a [token](#github-token-optional) is recommended here.

The highlighting uses GitHub's own syntax theme colors, so it matches the selected light/dark theme.

Expand Down
3 changes: 3 additions & 0 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ async function cachedApi(path, { transform = (body) => body, raw = false, fallba
const reset = new Date(parseInt(res.headers.get('x-ratelimit-reset') || '0', 10) * 1000);
throw new ApiError(res.status, `GitHub API rate limit exceeded until ${reset.toLocaleTimeString()}`);
}
if (res.status === 404 && !token) {
throw new ApiError(404, `GitHub API returned 404 for ${path} (private repository? Set a token in the options)`);
}
throw new ApiError(res.status, `GitHub API returned ${res.status} for ${path}`);
}

Expand Down
18 changes: 16 additions & 2 deletions extension/content/languages.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@
.catch((e) => ({ ok: false, error: e.message }))
.then((response) => {
if (!response || !response.ok) {
console.warn(`[Spice for GitHub] Could not load language stats: ${response && response.error}`);
// 404 is what the API answers for private repositories without a token, which is expected
const log = response && response.status === 404 ? console.debug : console.warn;
log(`[Spice for GitHub] Could not load language stats: ${response && response.error}`);
}
entry.result = response || { ok: false, error: 'No response' };
entry.time = Date.now();
Expand Down Expand Up @@ -241,5 +243,17 @@
onUpdate = handler;
}

(root.SpiceGH = root.SpiceGH || {}).languages = { run, clear: removeAll, currentRepo, setUpdateHandler };
/** Returns the cache entry of a repository without requesting it: {result, time} or undefined. */
function peekStats(repo) {
return statsCache.get(repo.key);
}

(root.SpiceGH = root.SpiceGH || {}).languages = {
run,
clear: removeAll,
currentRepo,
setUpdateHandler,
requestStats,
peekStats,
};
})(globalThis);
8 changes: 6 additions & 2 deletions extension/content/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
(function (root) {
'use strict';

const { highlight, languages, DEFAULT_SETTINGS } = root.SpiceGH;
const { highlight, languages, repoList, DEFAULT_SETTINGS } = root.SpiceGH;
const settings = { ...DEFAULT_SETTINGS };
let scheduled = null;

function run() {
scheduled = null;
try {
if (settings.highlight) highlight.run();
if (settings.languageStats) languages.run(settings);
if (settings.languageStats) {
languages.run(settings);
repoList.run(settings);
}
} catch (e) {
console.error('[Spice for GitHub]', e);
}
Expand All @@ -37,6 +40,7 @@
}
if (!settings.highlight) highlight.clear();
languages.clear(root.document);
repoList.clear(root.document);
schedule();
});

Expand Down
125 changes: 125 additions & 0 deletions extension/content/repolist.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Shows Spice as the primary language of repositories in repository lists (profiles, organizations, search results,
* stars) and in pinned repositories if Spice makes up the majority of the code. GitHub shows its largest known language there, which is wrong
* for Spice repositories, e.g. "Shell" for a repository with a few scripts and lots of Spice code.
*/
(function (root) {
'use strict';

const { SPICE } = root.SpiceGH.linguist;

const ITEM_SELECTOR = 'a[itemprop~="codeRepository"]';
// Pinned repositories of profiles and organizations have no itemprop, the first link of the card is the repository
const PINNED_SELECTOR = '.pinned-item-list-item';
const LANGUAGE_SELECTOR = '[itemprop="programmingLanguage"]';
const MARK_ATTR = 'data-sgh-repo-lang';
// Every repository costs API requests, so only a few are loaded at the same time
const MAX_PENDING = 4;

function repoOf(link) {
const m = /^\/([^/]+)\/([^/]+?)\/?$/.exec(new URL(link.getAttribute('href'), root.location.href).pathname);
return m ? { owner: m[1], repo: m[2], key: `${m[1]}/${m[2]}`.toLowerCase() } : null;
}

/** Finds all repository cards on the page: {repo, item}. */
function findItems(doc) {
const found = [];
for (const link of doc.querySelectorAll(ITEM_SELECTOR)) {
const item = link.closest('li, article');
const repo = repoOf(link);
if (repo && item) found.push({ repo, item });
}
for (const item of doc.querySelectorAll(PINNED_SELECTOR)) {
const link = Array.from(item.querySelectorAll('a[href]')).find((a) => repoOf(a));
const repo = link && repoOf(link);
if (repo) found.push({ repo, item });
}
return found;
}

/** Returns the row of metadata (language, stars, ...) of a card, which has no language yet. */
function metadataRow(item) {
const time = item.querySelector('relative-time');
if (time && time.parentElement !== item && time.parentElement.parentElement) return time.parentElement.parentElement;
const stars = item.querySelector('a[href$="/stargazers"], a[href$="/forks"], a[href$="/network/members"]');
return stars ? stars.parentElement : item.querySelector('p.f6');
}

/** Returns the element wrapping the colored dot and the name of the language of a list item, if there is one. */
function languageWrapper(item) {
const name = item.querySelector(LANGUAGE_SELECTOR);
return name ? name.parentElement : null;
}

/** Creates the language element for repositories that GitHub shows without a language. */
function createWrapper(doc, item) {
const row = metadataRow(item);
if (!row) return null;
const wrapper = doc.createElement('span');
wrapper.className = 'd-inline-block mr-3';
wrapper.setAttribute(MARK_ATTR, 'created');
const color = doc.createElement('span');
color.className = 'repo-language-color';
const name = doc.createElement('span');
name.setAttribute('itemprop', 'programmingLanguage');
// GitHub renders whitespace between the dot and the name
wrapper.append(color, doc.createTextNode(' '), name);
// The language comes first in the row of metadata, before stars, forks, etc.
row.insertBefore(wrapper, row.firstChild);
return wrapper;
}

function setLanguage(wrapper, name, color) {
const nameEl = wrapper.querySelector(LANGUAGE_SELECTOR);
const colorEl = wrapper.querySelector('.repo-language-color');
if (wrapper.getAttribute(MARK_ATTR) !== 'created' && !wrapper.hasAttribute('data-sgh-original')) {
const original = [nameEl.textContent, colorEl ? colorEl.style.backgroundColor : ''];
wrapper.setAttribute('data-sgh-original', JSON.stringify(original));
}
nameEl.textContent = name;
if (colorEl) colorEl.style.backgroundColor = color;
if (wrapper.getAttribute(MARK_ATTR) !== 'created') wrapper.setAttribute(MARK_ATTR, SPICE);
}

function restore(wrapper) {
if (wrapper.getAttribute(MARK_ATTR) === 'created') return wrapper.remove();
const [name, color] = JSON.parse(wrapper.getAttribute('data-sgh-original'));
wrapper.querySelector(LANGUAGE_SELECTOR).textContent = name;
const colorEl = wrapper.querySelector('.repo-language-color');
if (colorEl) colorEl.style.backgroundColor = color;
wrapper.removeAttribute(MARK_ATTR);
wrapper.removeAttribute('data-sgh-original');
}

function clear(doc) {
for (const wrapper of doc.querySelectorAll(`[${MARK_ATTR}]`)) restore(wrapper);
}

function run(settings, doc = root.document) {
const { requestStats, peekStats } = root.SpiceGH.languages;
let pending = 0;
for (const { repo, item } of findItems(doc)) {
let entry = peekStats(repo);
if (!entry) {
if (pending >= MAX_PENDING) continue;
entry = requestStats(repo);
}
if (!entry.result) {
pending++;
continue;
}

const { ok, stats } = entry.result;
const wrapper = languageWrapper(item);
const total = ok ? Object.values(stats.merged).reduce((a, b) => a + b, 0) : 0;
if (!ok || total === 0 || stats.spice.bytes * 2 <= total) {
if (wrapper && wrapper.hasAttribute(MARK_ATTR)) restore(wrapper);
continue;
}
const target = wrapper || createWrapper(doc, item);
if (target) setLanguage(target, SPICE, settings.spiceColor);
}
}

(root.SpiceGH = root.SpiceGH || {}).repoList = { run, clear };
})(globalThis);
1 change: 1 addition & 0 deletions extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"shared/linguist.js",
"content/highlight.js",
"content/languages.js",
"content/repolist.js",
"content/main.js"
],
"css": ["content/content.css"],
Expand Down
1 change: 1 addition & 0 deletions test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const CONTENT_SCRIPTS = [
'shared/linguist.js',
'content/highlight.js',
'content/languages.js',
'content/repolist.js',
];
const sources = CONTENT_SCRIPTS.map((file) => fs.readFileSync(path.join(__dirname, '..', 'extension', file), 'utf8'));

Expand Down
74 changes: 74 additions & 0 deletions test/repolist.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { createPage } = require('./helpers.js');
const linguist = require('../extension/shared/linguist.js');

const SETTINGS = { highlight: true, languageStats: true, spiceColor: '#9d0208' };

function stats(languages, spiceBytes) {
const spice = { bytes: spiceBytes, files: 3, reassigned: {}, truncated: false };
return { ok: true, stats: { languages, spice, merged: linguist.mergeLanguages(languages, spice) } };
}

const ITEM = (name, language) => `
<li itemprop="owns"><a href="/me/${name}" itemprop="name codeRepository">${name}</a>
<div>${
language
? `<span class="d-inline-block mr-3"><span class="repo-language-color" style="background-color: rgb(137, 224, 81)"></span><span itemprop="programmingLanguage">${language}</span></span>`
: ''
}<span>Updated <relative-time>now</relative-time></span></div></li>`;

async function runRepoList(window) {
const { languages, repoList } = window.SpiceGH;
const updated = new Promise((resolve) => languages.setUpdateHandler(resolve));
repoList.run(SETTINGS);
await updated;
repoList.run(SETTINGS);
}

test('shows Spice as language if it is the majority, and restores on clear', async () => {
const window = createPage(ITEM('shaker', 'Shell') + ITEM('other', 'Shell'), 'https://github.com/me?tab=repositories', (m) =>
m.repo === 'shaker' ? stats({ Shell: 100 }, 900) : stats({ Shell: 100 }, 50),
);
await runRepoList(window);
const langs = [...window.document.querySelectorAll('[itemprop="programmingLanguage"]')].map((e) => e.textContent);
assert.deepEqual(langs, ['Spice', 'Shell']);
assert.equal(window.document.querySelector('.repo-language-color').style.backgroundColor, 'rgb(157, 2, 8)');

window.SpiceGH.repoList.clear(window.document);
assert.equal(window.document.querySelector('[itemprop="programmingLanguage"]').textContent, 'Shell');
assert.equal(window.document.querySelector('.repo-language-color').style.backgroundColor, 'rgb(137, 224, 81)');
});

test('adds a language to repositories GitHub shows none for', async () => {
const window = createPage(ITEM('pure', null), 'https://github.com/me?tab=repositories', () => stats({}, 500));
await runRepoList(window);
const name = window.document.querySelector('[itemprop="programmingLanguage"]');
assert.equal(name.textContent, 'Spice');
assert.equal(name.closest("div").firstElementChild, name.parentElement);
});

const PINNED = (name, language) => `
<div class="pinned-item-list-item"><div><a href="/spicelang/${name}" class="Link"><span class="owner">spicelang</span>/<span class="repo">${name}</span></a></div>
<p class="pinned-item-desc">desc</p>
<p class="mb-0 f6">${
language
? `<span class="d-inline-block mr-3"><span class="repo-language-color" style="background-color: rgb(0, 0, 0)"></span><span itemprop="programmingLanguage">${language}</span></span>`
: ''
}<a href="/spicelang/${name}/stargazers">88</a></p></div>`;

test('shows Spice in pinned repositories, with and without a GitHub language', async () => {
const window = createPage(PINNED('spice', 'LLVM') + PINNED('pure', null), 'https://github.com/spicelang', () =>
stats({ LLVM: 100 }, 900),
);
const { languages, repoList } = window.SpiceGH;
const updated = new Promise((resolve) => languages.setUpdateHandler(resolve));
repoList.run(SETTINGS);
await updated;
repoList.run(SETTINGS);
const cards = [...window.document.querySelectorAll('.pinned-item-list-item')];
for (const card of cards) {
assert.equal(card.querySelector('[itemprop="programmingLanguage"]').textContent, 'Spice');
assert.equal(card.querySelector('p.f6').firstElementChild.querySelector('[itemprop]').textContent, 'Spice');
}
});
Loading