GitHub info on Typst Universe

Display information about the GitHub repository on typst.app/universe

// ==UserScript==
// @name         GitHub info on Typst Universe
// @name:zh-CN   在 Typst Universe 上显示 GitHub 信息
// @namespace    http://tampermonkey.net/
// @version      0.1.1
// @description  Display information about the GitHub repository on typst.app/universe
// @description:zh-CN 在 typst.app/universe 上显示 GitHub 仓库的信息
// @author       Y.D.X.
// @match        https://typst.app/universe/package/*
// @icon         https://simpleicons.org/icons/typst.svg
// @license      MIT
// @supportURL   https://gist.github.com/YDX-2147483647/48d1169d35101cde9e2b20aff178da22
// @grant        GM_xmlhttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @connect      api.github.com
// ==/UserScript==
;(async function () {
  "use strict"

  /**
   * @typedef RepoMeta
   * @property {string} owner
   * @property {string} name
   */

  /**
   * @typedef RepoInfo
   * @property {number} forkCount
   * @property {number} stargazerCount
   * @property {Date} pushedAt
   * @property {{ totalCount: number }} openIssues
   * @property {{ totalCount: number }} allIssues
   * @property {number} downloadCount
   * @property {number} contributorCount
   */

  class RepoMetaUtil {
    /**
     * @param {string} owner_name
     * @returns {RepoMeta | null}
     */
    static parse(owner_name) {
      const split = owner_name.indexOf("/")
      if (!split) {
        return null
      }
      return {
        owner: owner_name.slice(0, split),
        name: owner_name.slice(split + 1),
      }
    }

    /**
     * @param {RepoMeta} meta
     * @returns {string}
     */
    static stringify(meta) {
      return `${meta.owner}/${meta.name}`
    }
  }

  class RepoInfoCache {
    /**
     * @param {RepoMeta} meta
     * @returns {Promise<RepoInfo | null>}
     */
    static async get(meta) {
      /** @type {Record<string, { updatedAt: string, info: RepoInfo }>} */
      const cache = JSON.parse(await GM.getValue("CACHE", "{}"))

      const now = new Date()

      // Drop outdated entries
      const valid_cache = Object.fromEntries(
        Object.entries(cache).filter(([k, v]) => {
          const updatedAt = new Date(v.updatedAt)
          return updatedAt >= now - 7 * 24 * 60 * 60 * 1000 // 1 week
        }),
      )
      await GM.setValue("CACHE", JSON.stringify(valid_cache))

      const key = RepoMetaUtil.stringify(meta)
      const cached = valid_cache[key]?.info
      if (cached) {
        // Convert pushedAt from string to Date
        if (typeof cached.pushedAt === "string") {
          cached.pushedAt = new Date(cached.pushedAt)
        }
        return cached
      }
      return null
    }
    /**
     * @param {RepoMeta} meta
     * @param {RepoInfo} info
     * @returns {Promise<void>}
     */
    static async set(meta, info) {
      /** @type {Record<string, { updatedAt: string, info: RepoInfo }>} */
      const cache = JSON.parse(await GM.getValue("CACHE", "{}"))

      const key = RepoMetaUtil.stringify(meta)
      cache[key] = {
        updatedAt: new Date().toISOString(),
        info,
      }
      await GM.setValue("CACHE", JSON.stringify(cache))
    }
  }

  /**
   * Locates the GitHub repository link in the Typst Universe package page.
   * @returns {{ element: HTMLAnchorElement, meta: RepoMeta } | null}
   * Return null if not found.
   */
  function locate_repo_link() {
    const dl = document.querySelector("#about dl")
    if (dl === null) {
      return null
    }

    for (const dt of dl.querySelectorAll(":scope > dt")) {
      if (dt.textContent === "Repository:") {
        const dd = dt.nextElementSibling
        if (dd.tagName !== "DD") {
          continue
        }
        /** @type {HTMLAnchorElement | null} */
        const anchor = dd.querySelector(":scope > a")
        if (anchor === null || !anchor.href.startsWith("https://github.com/")) {
          continue
        }

        const owner_name = remove_suffix(
          anchor.href.slice("https://github.com/".length),
          ".git",
        )
        const meta = RepoMetaUtil.parse(owner_name)
        if (meta === null) {
          continue
        }

        return { element: anchor, meta }
      }
    }

    return null
  }

  /**
   * Fetches and builds a container element with GitHub repository information.
   * @param {RepoMeta} meta
   * @param {RepoInfo} info
   * @returns {Promise<HTMLSpanElement>}
   */
  async function render_repo({ owner, name }, info) {
    const openIssuesRatio = info.allIssues.totalCount > 0
      ? (info.openIssues.totalCount / info.allIssues.totalCount)
      : 0

    const entry = (title, icon, value, url = null) => {
      const body = `${icon} ${value}`
      const attrs = `title="${title}" style="display: inline-block;"`

      return url === null
        ? `<span ${attrs}>${body}</span>`
        : `<a href="https://github.com/${owner}/${name}${url}" ${attrs}>${body}</a>`
    }

    const container = document.createElement("span")
    // Inspired by https://github.com/best-of-lists/
    container.innerHTML = "(" + [
      entry("stars", "⭐", info.stargazerCount, "/stargazers"),
      entry(
        "contributors",
        "👩‍💻",
        info.contributorCount,
        "/contributors",
      ),
      entry("forks", "🔀", info.forkCount, "/forks"),
      info.downloadCount > 0
        ? entry(
          "downloads of all releases",
          "📥",
          info.downloadCount,
          "/releases/",
        )
        : null,
      entry(
        "issues",
        "📋",
        `${info.allIssues.totalCount} - ${
          (openIssuesRatio * 100).toFixed(0)
        }% open`,
        "/issues/",
      ),
      entry(
        "last pushed at",
        "⏱️",
        `<time datetime="${info.pushedAt.toISOString()}">${info.pushedAt.toLocaleDateString()}</time>`,
      ),
    ].filter((it) => it !== null).join(" · ") + ")"

    return container
  }

  /**
   * Fetches all data of a repo from GitHub.
   * @param {RepoMeta} meta
   * @param {string} token
   * @returns {Promise<RepoInfo>}
   */
  async function fetch_repo_info({ owner, name }, token) {
    const [{ repo }, contributors] = await Promise.all([
      fetch_GitHub_GraphQL(
        `
        query($owner: String!, $name: String!) {
          repo: repository(owner: $owner, name: $name) {
            forkCount
            stargazerCount
            pushedAt
            openIssues: issues(states: [OPEN]) { totalCount }
            allIssues: issues(states: [OPEN, CLOSED]) { totalCount }
            releases(last: 100) {
              nodes {
                releaseAssets(first: 100) { totalCount }
              }
            }
          }
        }`,
        { owner, name },
        token,
      ),
      fetch_repo_contributor_count({ owner, name }, token),
    ])
    const { releases, pushedAt, ...others } = repo
    return {
      downloadCount: sum(
        releases.nodes.map((release) => release.releaseAssets.totalCount),
      ),
      pushedAt: new Date(pushedAt),
      ...others,
      contributorCount: contributors,
    }
  }

  /**
   * Fetches all data of a repo from GitHub with cache.
   * @param {RepoMeta} meta
   * @param {string} token
   * @returns {Promise<RepoInfo>}
   */
  async function fetch_repo_info_cached(meta, token) {
    const cached = await RepoInfoCache.get(meta)
    if (cached) {
      return cached
    }

    const latest = await fetch_repo_info(meta, token)
    await RepoInfoCache.set(meta, latest)
    return latest
  }

  /**
   * Performs a GM_xmlhttpRequest wrapped in a Promise.
   * @param {string} url
   * @param {object} [options={}] The request options (method, headers, body).
   * @returns {Promise<any>}
   */
  function GM_fetch(url, options = {}) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: options.method ?? "GET",
        url,
        headers: options.headers ?? {},
        data: options.body,
        onload: resolve,
        onerror: reject,
      })
    })
  }

  /**
   * Fetches data from the GitHub GraphQL API.
   * @param {string} query - The GraphQL query string.
   * @param {Record<string, string>} variables - The variables for the query.
   * @param {string} token
   * @returns {Promise<any>} The `data` returned from the API.
   * @throws {Error} If the HTTP status is not 200 or if the API returns errors.
   */
  async function fetch_GitHub_GraphQL(query, variables, token) {
    const response = await GM_fetch("https://api.github.com/graphql", {
      method: "POST",
      headers: {
        "Authorization": "Bearer " + token,
        "Content-Type": "application/json",
        "Accept": "application/json",
      },
      body: JSON.stringify({ query, variables }),
    })
    if (response.status !== 200) {
      throw new Error("HTTP error " + response.status)
    }
    const data = JSON.parse(response.responseText)
    if (data.errors) {
      throw new Error("GitHub GraphQL error " + JSON.stringify(data.errors))
    }
    return data.data
  }

  /**
   * Fetches the total number of contributors for a given GitHub repository using the REST API.
   * @param {RepoMeta} meta
   * @param {string} token
   * @returns {Promise<number>} The total number of contributors.
   */
  async function fetch_repo_contributor_count({ owner, name }, token) {
    // GraphQL API does not have this entry. Resort to REST API.
    const response = await GM_fetch(
      `https://api.github.com/repos/${owner}/${name}/contributors?page=1&per_page=1&anon=True`,
      {
        headers: { "Authorization": "token " + token },
      },
    )

    /** @type {Map<string, string>} */
    const headers = new Map(
      response.responseHeaders.trim().split(/[\r\n]+/).map((line) => {
        // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getAllResponseHeaders#examples
        const parts = line.split(": ", 2)
        const header = parts.shift()
        const value = parts.join(": ")
        return [header, value]
      }),
    )
    const contributors = parseInt(
      headers.get("link")?.match(/\?page=(\d+)&[^>]+>; rel="last"/)?.at(1) ??
        "0",
    )
    return contributors
  }

  /**
   * Sums the values in an array.
   * @param {number[]} arr - The array of numbers.
   * @returns {number} The sum of the array elements.
   */
  function sum(arr) {
    return arr.reduce((total, current) => total + current, 0)
  }

  /**
   * Remove the suffix if it exists.
   * @param {string} str
   * @param {string} suffix
   * @returns {string}
   */
  function remove_suffix(str, suffix) {
    if (suffix && str.endsWith(suffix)) {
      return str.slice(0, -suffix.length)
    }
    return str
  }

  /**
   * Builds a button for setting or resetting the GitHub token.
   * @returns {HTMLButtonElement}
   */
  function build_token_button() {
    const button = document.createElement("button")
    button.textContent = "🔑"
    button.title = "Set/reset GitHub token"
    button.ariaLabel = button.title
    button.style.margin = "4px"
    button.style.padding = "4px"
    return button
  }

  const match = locate_repo_link()

  if (match !== null) {
    const { element, meta } = match

    let info = null
    try {
      const token = await GM.getValue("GITHUB_TOKEN", "")
      info = await fetch_repo_info_cached(meta, token)
    } catch (error) {
      console.error(error)
    }
    const succeeded = info !== null

    if (succeeded) {
      const annotation = await render_repo(meta, info)
      element.parentElement.appendChild(annotation)
    }

    const token_button = element.parentElement.appendChild(build_token_button())
    token_button.onclick = async () => {
      const token = prompt(
        [
          "🔑 Set/reset your GitHub token",
          "",
          "This script fetches data from GitHub. Setting a token will increase the rate limit of GitHub API.",
          "",
          "You can generate a new token here (no OAuth scopes are required):",
          "https://github.com/settings/tokens/new?description=Typst%20Universe%20GitHub%20Info&default_expires_at=none",
          "",
          "Leave blank to remove the token.",
        ].join("\n"),
        await GM.getValue("GITHUB_TOKEN", ""),
      )

      if (token !== null) {
        await GM.setValue("GITHUB_TOKEN", token)

        // Retry with the new token
        if (!succeeded) {
          info = await fetch_repo_info_cached(meta, token)
          const annotation = await render_repo(meta, info)
          token_button.insertAdjacentElement("beforebegin", annotation)
        }
      }
    }
  }
})()