Skip to content
Open
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
4 changes: 3 additions & 1 deletion src/stat/Stat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export default function Stat({ org, repo, branch }: Props) {
return (
<a
className="Link Link--muted"
href={`https://ghloc.vercel.app/${org}/${repo}?branch=${branch}`}
href={`https://ghloc.dev/${org}/${repo}${
branch ? `?branch=${encodeURIComponent(branch)}` : ""
}`}
>
<svg
className="octicon octicon-repo-forked mr-2"
Expand Down
2 changes: 1 addition & 1 deletion src/stat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function main() {
}
}

fetchLoc(org, repo, branch)
fetchLoc(org, repo, branch, isPublic)
.then((locData) => {
updateStat(stat, locData.loc)

Expand Down
80 changes: 52 additions & 28 deletions src/stat/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,67 @@ import { JSX, render } from "preact"
import { LocData } from "./loader"
import { openFallbackPage } from "./util"

function isInjected(root: Element) {
return root.querySelector("#github-loc") !== null
function isPublicRepository() {
const publicMeta = document.querySelector('meta[name="octolytics-dimension-repository_public"]')
const publicValue = publicMeta?.getAttribute("content")

if (publicValue !== null) {
return publicValue === "true"
}

const repoVisibility = document.evaluate(
'//*[@id="repo-title-component"]/span[2]',
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue

return repoVisibility?.textContent !== "Private"
}

export function locateRoot(): Promise<[Element, boolean]> {
return new Promise((resolve) => {
const root = document.evaluate(
'//h2[text()="About" and not(@class="heading-element")]',
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue?.parentElement

const repoVisibility = document.evaluate(
'//*[@id="repo-title-component"]/span[2]',
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue

if (root && !isInjected(root)) {
resolve([root, repoVisibility?.textContent !== "Private"])
let observer: MutationObserver | undefined

const tryLocate = () => {
const root = document.evaluate(
'//h2[normalize-space(.)="About" and not(@class="heading-element")]',
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
).singleNodeValue?.parentElement

if (!root) {
return false
}

observer?.disconnect()
resolve([root, isPublicRepository()])
return true
}

if (!tryLocate()) {
observer = new MutationObserver(tryLocate)
observer.observe(document.documentElement, { childList: true, subtree: true })
}
})
}

export function injectStat(root: Element, stat: JSX.Element) {
const div = document.createElement("div")
div.className = "mt-2"
div.id = "github-loc"

if (root.lastElementChild?.firstElementChild?.textContent?.includes("Report")) {
root.insertBefore(div, root.lastElementChild)
} else {
root.appendChild(div)
const existing = root.querySelector<HTMLElement>("#github-loc")
const div = existing ?? document.createElement("div")

if (!existing) {
div.className = "mt-2"
div.id = "github-loc"

if (root.lastElementChild?.firstElementChild?.textContent?.includes("Report")) {
root.insertBefore(div, root.lastElementChild)
} else {
root.appendChild(div)
}
}

render(stat, div)
Expand Down
239 changes: 193 additions & 46 deletions src/stat/loader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/// <reference types="chrome" />

import { now } from "./util"

export interface LocData {
Expand All @@ -6,24 +8,196 @@ export interface LocData {
lastFetched: number
}

interface GitHubTreeEntry {
path: string
sha: string
type: string
}

interface GitHubTreeResponse {
tree: GitHubTreeEntry[]
truncated: boolean
}

interface GitHubBlobResponse {
content: string
encoding: string
}

interface GitHubRepositoryResponse {
default_branch: string
}

const GITHUB_API = "https://api.github.com"
const BLOB_BATCH_SIZE = 10

function makeKey(org: string, repo: string, branch: string) {
return org + "/" + repo + "/" + branch
}

async function sha1(str: string) {
const encoder = new TextEncoder()
const hash = await crypto.subtle.digest("SHA-1", encoder.encode(str))
async function fetchGitHubJson<T>(url: string, token: string): Promise<T> {
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
})
const data = await response.json()

if (!response.ok) {
throw new Error(data.message || `GitHub API request failed (${response.status})`)
}

return data as T
}

function decodeBase64(value: string) {
const binary = atob(value.replace(/\s/g, ""))
const bytes = new Uint8Array(binary.length)

for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index)
}

return bytes
}

function countTextLines(bytes: Uint8Array) {
if (bytes.includes(0)) {
return 0
}

const text = new TextDecoder().decode(bytes)

if (text.length === 0) {
return 0
}

const lines = text.split(/\r\n|\r|\n/)
return lines.length - (/(?:\r\n|\r|\n)$/.test(text) ? 1 : 0)
}

function getFileExtension(path: string) {
const fileName = path.split("/").pop() || path
const dotIndex = fileName.lastIndexOf(".")

return dotIndex > 0 ? fileName.slice(dotIndex).toLowerCase() : fileName
}

function isIgnored(path: string, ignoredFiles: string[]) {
return ignoredFiles.some((ignored) => path.toLowerCase().endsWith(ignored.toLowerCase()))
}

async function fetchLocalLoc(
org: string,
repo: string,
branch: string,
ignoredFiles: string[],
): Promise<LocData> {
const accessToken = await chrome.storage.sync.get("accessToken")

if (typeof accessToken.accessToken !== "string" || accessToken.accessToken.length === 0) {
throw new Error("A GitHub access token is required for private repositories")
}

const token = accessToken.accessToken
const repository = branch
? null
: await fetchGitHubJson<GitHubRepositoryResponse>(
`${GITHUB_API}/repos/${encodeURIComponent(org)}/${encodeURIComponent(repo)}`,
token,
)
const ref = branch || repository!.default_branch
const tree = await fetchGitHubJson<GitHubTreeResponse>(
`${GITHUB_API}/repos/${encodeURIComponent(org)}/${encodeURIComponent(
repo,
)}/git/trees/${encodeURIComponent(ref)}?recursive=1`,
token,
)

if (tree.truncated) {
throw new Error("The repository tree is too large to count in the browser")
}

const files = tree.tree.filter(
(entry) => entry.type === "blob" && !isIgnored(entry.path, ignoredFiles),
)
const locByLangs: { [lang: string]: number } = {}

for (let index = 0; index < files.length; index += BLOB_BATCH_SIZE) {
const batch = files.slice(index, index + BLOB_BATCH_SIZE)
const lineCounts = await Promise.all(
batch.map(async (file) => {
const blob = await fetchGitHubJson<GitHubBlobResponse>(
`${GITHUB_API}/repos/${encodeURIComponent(org)}/${encodeURIComponent(
repo,
)}/git/blobs/${file.sha}`,
token,
)

if (blob.encoding !== "base64") {
return { extension: getFileExtension(file.path), lines: 0 }
}

return {
extension: getFileExtension(file.path),
lines: countTextLines(decodeBase64(blob.content)),
}
}),
)

for (const { extension, lines } of lineCounts) {
locByLangs[extension] = (locByLangs[extension] || 0) + lines
}
}

return {
loc: Object.values(locByLangs).reduce((total, lines) => total + lines, 0),
locByLangs,
lastFetched: 0,
}
}

async function fetchPublicLoc(org: string, repo: string, branch: string, ignoredFiles: string[]) {
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/")
const params = new URLSearchParams({ pretty: "false" })

if (ignoredFiles.length > 0) {
params.set("filter", ignoredFiles.map((ignored) => `!${ignored}$`).join(","))
}

const branchPath = encodedBranch ? `/${encodedBranch}` : ""
const configuredApiUrl = import.meta.env.VITE_API_URL
const apiBaseUrl =
typeof configuredApiUrl === "string" && configuredApiUrl.length > 0
? configuredApiUrl.replace(/\/$/, "")
: "https://ghloc.ifels.dev"
const headers = new Headers()
const authToken = import.meta.env.VITE_AUTH_TOKEN

if (typeof authToken === "string" && authToken.length > 0) {
headers.set("Ghloc-Authorization", authToken)
}

return Array.from(new Uint8Array(hash))
.map((v) => v.toString(16).padStart(2, "0"))
.join("")
const response = await fetch(
`${apiBaseUrl}/${encodeURIComponent(org)}/${encodeURIComponent(repo)}${branchPath}?${params}`,
{ headers },
)
const data = await response.json()

if (!response.ok || data.error) {
throw new Error(data.error || `LOC API request failed (${response.status})`)
}

return data as LocData
}

export function loadLoc(org: string, repo: string, branch: string): Promise<LocData | null> {
return new Promise((resolve) => {
const key = makeKey(org, repo, branch)

chrome.storage.local.get(key, (data) => {
chrome.storage.local.get(key, (data: { [key: string]: unknown }) => {
const locData = data[key] as LocData

if (
Expand All @@ -40,46 +214,19 @@ export function loadLoc(org: string, repo: string, branch: string): Promise<LocD
})
}

export async function fetchLoc(org: string, repo: string, branch: string): Promise<LocData> {
let url = `https://ghloc-api.vercel.app/${org}/${repo}/${branch}`

const accessToken = await chrome.storage.sync.get("accessToken")
export async function fetchLoc(
org: string,
repo: string,
branch: string,
isPublic: boolean,
): Promise<LocData> {
const ignoredFiles = await chrome.storage.sync.get("ignoredFiles")

const headers = new Headers({
"Ghloc-Authorization": import.meta.env.VITE_AUTH_TOKEN,
})

if (Array.isArray(ignoredFiles.ignoredFiles) && ignoredFiles.ignoredFiles.length > 0) {
url += "?match="

for (const ignored of ignoredFiles.ignoredFiles) {
url += "!" + ignored + "$,"
}

url = url.substring(0, url.length - 1)
}

if (typeof accessToken.accessToken === "string" && accessToken.accessToken.length > 0) {
headers.append("Authorization", `Bearer ${accessToken.accessToken}`)

url += url.includes("?match") ? "&" : "?"
url += "salt=" + (await sha1(accessToken.accessToken))
}

let data: LocData = await fetch(url, { headers })
.then((res) => res.json())
.then((data) => {
if (typeof data !== "object") {
throw new Error("Invalid response: " + JSON.stringify(data))
}

if (data.error) {
throw new Error(data.error)
}

return data
})
const ignoredList = Array.isArray(ignoredFiles.ignoredFiles)
? (ignoredFiles.ignoredFiles as string[])
: []
const data = isPublic
? await fetchPublicLoc(org, repo, branch, ignoredList)
: await fetchLocalLoc(org, repo, branch, ignoredList)

data.lastFetched = now()
chrome.storage.local.set({ [makeKey(org, repo, branch)]: data })
Expand Down
Loading