diff --git a/README.md b/README.md index 77d9369..b851e56 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/extension/background.js b/extension/background.js index 09b4294..d3e2c44 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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}`); } diff --git a/extension/content/languages.js b/extension/content/languages.js index f2125da..74cff7c 100644 --- a/extension/content/languages.js +++ b/extension/content/languages.js @@ -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(); @@ -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); diff --git a/extension/content/main.js b/extension/content/main.js index e089165..c928088 100644 --- a/extension/content/main.js +++ b/extension/content/main.js @@ -5,7 +5,7 @@ (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; @@ -13,7 +13,10 @@ 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); } @@ -37,6 +40,7 @@ } if (!settings.highlight) highlight.clear(); languages.clear(root.document); + repoList.clear(root.document); schedule(); }); diff --git a/extension/content/repolist.js b/extension/content/repolist.js new file mode 100644 index 0000000..db18ce4 --- /dev/null +++ b/extension/content/repolist.js @@ -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); diff --git a/extension/manifest.json b/extension/manifest.json index 9bff971..6598c68 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -36,6 +36,7 @@ "shared/linguist.js", "content/highlight.js", "content/languages.js", + "content/repolist.js", "content/main.js" ], "css": ["content/content.css"], diff --git a/test/helpers.js b/test/helpers.js index 1cf8654..e34f3ef 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -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')); diff --git a/test/repolist.test.js b/test/repolist.test.js new file mode 100644 index 0000000..e5ff23f --- /dev/null +++ b/test/repolist.test.js @@ -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) => ` +
  • ${name} +
    ${ + language + ? `${language}` + : '' + }Updated now
  • `; + +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) => ` +
    spicelang/${name}
    +

    desc

    +

    ${ + language + ? `${language}` + : '' + }88

    `; + +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'); + } +});