UNPKG

alacritty-theme-switch

Version:
350 lines (349 loc) 17.4 kB
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { if (kind === "m") throw new TypeError("Private method is not writable"); if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; }; var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); }; var _GitHubClient_instances, _GitHubClient_owner, _GitHubClient_repo, _GitHubClient_ref, _GitHubClient_apiBaseUrl, _GitHubClient_rawBaseUrl, _GitHubClient_token, _GitHubClient_downloadLicense, _GitHubClient_tryDownloadLicenseFilesSequentially, _GitHubClient_tryDownloadLicenseFile, _GitHubClient_fetchJson, _GitHubClient_fetchRawContent; import * as dntShim from "../../../_dnt.shims.js"; import { basename, join } from "../../../deps/jsr.io/@std/path/1.1.4/mod.js"; import { err, errAsync, fromPromise, fromSafePromise, ok, okAsync, Result, } from "neverthrow"; import pMap from "p-map"; import { safeEnsureDir, safeWriteFile, } from "../../utils/fs-utils.js"; import { safeParseTomlContent } from "../../utils/toml-utils.js"; import { Theme } from "../theme.js"; import { FileDownloadError, GitHubApiError, InvalidRepositoryUrlError, NoLicenseFileFoundError, } from "./errors.js"; /** * GitHub client for fetching Alacritty themes from a remote repository. * * This client provides methods to list and download TOML theme files from a GitHub repository. * It uses GitHub's REST API v3 for listing files, but downloads content directly from * raw.githubusercontent.com to avoid API rate limits. * * API rate limits (only applies to listing): * - Without authentication: 60 requests per hour * - With authentication: 5000 requests per hour * * Raw content downloads are not subject to API rate limits. * * Note: This class is not exported directly. Use the `createGitHubClient` factory function * to create instances with proper error handling. * * @internal */ class GitHubClient { /** * Creates a new GitHub client for the specified repository. * * This constructor assumes that the owner and repo values are well-formed. * Use the `createGitHubClient` factory function for URL parsing and validation. * * @param owner - GitHub repository owner * @param repo - GitHub repository name * @param ref - Git reference (branch, tag, or commit SHA) to use for downloads (default: "master") * @param token - Optional GitHub personal access token for authentication * * @internal */ constructor(owner, repo, ref = "master", token) { _GitHubClient_instances.add(this); _GitHubClient_owner.set(this, void 0); _GitHubClient_repo.set(this, void 0); _GitHubClient_ref.set(this, void 0); _GitHubClient_apiBaseUrl.set(this, "https://api.github.com"); _GitHubClient_rawBaseUrl.set(this, "https://raw.githubusercontent.com"); _GitHubClient_token.set(this, void 0); __classPrivateFieldSet(this, _GitHubClient_owner, owner, "f"); __classPrivateFieldSet(this, _GitHubClient_repo, repo, "f"); __classPrivateFieldSet(this, _GitHubClient_ref, ref, "f"); __classPrivateFieldSet(this, _GitHubClient_token, token, "f"); } /** * Lists all TOML theme files in the repository. * * This method fetches the repository tree recursively and filters for files * with a .toml extension. The returned themes have their path set to the * remote path in the repository. * * @returns A ResultAsync containing an array of themes or an error */ listThemes() { const url = `${__classPrivateFieldGet(this, _GitHubClient_apiBaseUrl, "f")}/repos/${__classPrivateFieldGet(this, _GitHubClient_owner, "f")}/${__classPrivateFieldGet(this, _GitHubClient_repo, "f")}/git/trees/HEAD?recursive=1`; return __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_fetchJson).call(this, url) .map((response) => { // Filter for TOML files only const tomlFiles = response.tree.filter((item) => item.type === "blob" && item.path.endsWith(".toml")); // Convert to Theme instances // Note: brightness defaults to "dark" since we haven't downloaded the files yet return tomlFiles.map((file) => new Theme(file.path, {}, null)); }); } /** * Downloads a single theme file from the repository to the specified output directory. * * This method fetches the file content directly from raw.githubusercontent.com, * which is not subject to GitHub API rate limits. The file will be saved with * its original filename in the output directory. If the output directory doesn't * exist, it will be created. * * @param remotePath - Path to the theme file in the repository (e.g., "themes/monokai_pro.toml") * @param outputPath - Local directory path where the theme should be saved * @returns A ResultAsync containing the downloaded theme with local path or an error * * @example * const client = new GitHubClient("https://github.com/alacritty/alacritty-theme"); * const result = await client.downloadTheme( * "themes/monokai_pro.toml", * "./my-themes" * ) * * if (result.isOk()) { * console.log(`Downloaded theme to: ${result.data.path}`); * } else { * console.error("Download failed:", result.error); * } */ downloadTheme(remotePath, outputPath) { // Construct raw content URL const repoUrl = `${__classPrivateFieldGet(this, _GitHubClient_rawBaseUrl, "f")}/${__classPrivateFieldGet(this, _GitHubClient_owner, "f")}/${__classPrivateFieldGet(this, _GitHubClient_repo, "f")}/`; const url = new URL(`${repoUrl}/refs/heads/${__classPrivateFieldGet(this, _GitHubClient_ref, "f")}/${remotePath}`); return safeEnsureDir(outputPath) .andThen(() => __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_fetchRawContent).call(this, url.toString())) .andThen((content) => { const filename = basename(remotePath); const localPath = join(outputPath, filename); return safeParseTomlContent(content) .asyncAndThen((themeContent) => { return safeWriteFile(localPath, content).map(() => { return new Theme(localPath, themeContent, null); }); }); }); } /** * Downloads all TOML theme files from the repository to the specified output directory. * * This method downloads each theme file sequentially from raw.githubusercontent.com, which is not * subject to API rate limits. It also downloads the repository's LICENSE file to preserve * proper attribution, naming it uniquely to avoid conflicts when downloading from multiple * repositories. * * If the output directory doesn't exist, it will be created. * * @param themes - Array of themes to download (typically obtained from listThemes()) * @param outputPath - Local directory path where themes should be saved * @param onProgress - Optional callback to report download progress * @returns A ResultAsync containing an array of downloaded themes with local paths or an error * * @example * ```typescript * const client = new GitHubClient("https://github.com/alacritty/alacritty-theme"); * const themesResult = await client.listThemes() * * if (themesResult.isOk()) { * const result = await client.downloadAllThemes(themesResult.data, "./my-themes") * * if (result.isOk()) { * console.log(`Downloaded ${result.data.length} themes`); * result.data.forEach(theme => { * console.log(` - ${theme.label}`); * }); * } else { * console.error("Download failed:", result.error); * } * } * ``` * * @example With progress callback * ```typescript * const client = new GitHubClient("https://github.com/alacritty/alacritty-theme"); * const themesResult = await client.listThemes() * * if (themesResult.isOk()) { * const result = await client.downloadAllThemes( * themesResult.data, * "./my-themes", * (current, total) => { * console.log(`Progress: ${current}/${total}`); * } * ) * } * ``` */ downloadAllThemes(themes, outputPath, onProgress) { const downloadResultsPromises = pMap(themes, async (theme) => { const downloaded = await this.downloadTheme(theme.path, outputPath); onProgress?.(themes.indexOf(theme) + 1, themes.length); return downloaded; }, { concurrency: 10 }); const downloadResult = fromSafePromise(downloadResultsPromises) .andThen((results) => Result.combine(results)) .andThen((themes) => { // Try to download LICENSE, but don't fail if it doesn't exist return __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_downloadLicense).call(this, outputPath) .orElse((error) => { // Silently succeed if no LICENSE file is found if (error._tag === "NoLicenseFileFoundError") { return okAsync(undefined); } return errAsync(error); }) .map(() => themes); // Return themes regardless of LICENSE download result }); return downloadResult; } } _GitHubClient_owner = new WeakMap(), _GitHubClient_repo = new WeakMap(), _GitHubClient_ref = new WeakMap(), _GitHubClient_apiBaseUrl = new WeakMap(), _GitHubClient_rawBaseUrl = new WeakMap(), _GitHubClient_token = new WeakMap(), _GitHubClient_instances = new WeakSet(), _GitHubClient_downloadLicense = function _GitHubClient_downloadLicense(outputPath) { // Common license file naming conventions, in order of preference const licenseFilenames = [ "LICENSE", "LICENSE.md", "LICENSE.txt", "license", "license.md", "license.txt", ]; return safeEnsureDir(outputPath) .map(() => __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_tryDownloadLicenseFilesSequentially).call(this, licenseFilenames, outputPath)) .andThen((result) => result); }, _GitHubClient_tryDownloadLicenseFilesSequentially = /** * Helper method to try downloading license files sequentially using a loop. * * This method iterates through each filename in the array. If a download succeeds, * it returns Ok. If a 404 is encountered, it tries the next filename. Any other * error is propagated immediately. * * @param filenames - Array of license filenames to try * @param outputPath - Local directory path where the LICENSE should be saved * @returns A Promise<Result> that resolves when a LICENSE is downloaded or all attempts fail */ async function _GitHubClient_tryDownloadLicenseFilesSequentially(filenames, outputPath) { for (const filename of filenames) { const result = await __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_tryDownloadLicenseFile).call(this, filename, outputPath); if (result.isOk()) { return result; } } return err(new NoLicenseFileFoundError()); }, _GitHubClient_tryDownloadLicenseFile = function _GitHubClient_tryDownloadLicenseFile(remoteFilename, outputPath) { const repoUrl = `${__classPrivateFieldGet(this, _GitHubClient_rawBaseUrl, "f")}/${__classPrivateFieldGet(this, _GitHubClient_owner, "f")}/${__classPrivateFieldGet(this, _GitHubClient_repo, "f")}/`; const url = new URL(`${repoUrl}/refs/heads/${__classPrivateFieldGet(this, _GitHubClient_ref, "f")}/${remoteFilename}`); return __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_fetchRawContent).call(this, url.toString()).andThen((content) => { // Create a unique filename based on repository owner and name const uniqueFilename = `LICENSE-${__classPrivateFieldGet(this, _GitHubClient_owner, "f")}-${__classPrivateFieldGet(this, _GitHubClient_repo, "f")}`; const localPath = join(outputPath, uniqueFilename); // Write LICENSE file to disk return safeWriteFile(localPath, content); }); }, _GitHubClient_fetchJson = function _GitHubClient_fetchJson(url) { const headers = { "Accept": "application/vnd.github.v3+json", "User-Agent": "alacritty-theme-switch", }; // Add authentication token if available if (__classPrivateFieldGet(this, _GitHubClient_token, "f")) { headers["Authorization"] = `Bearer ${__classPrivateFieldGet(this, _GitHubClient_token, "f")}`; } return safeFetch(url, (error) => new GitHubApiError(url, { cause: error })) .map((response) => response.json()); // TODO: Add parsing/validation! }, _GitHubClient_fetchRawContent = function _GitHubClient_fetchRawContent(url) { return safeFetch(url, (error) => new FileDownloadError(url, { cause: error })).map((response) => response.text()); }; /** * Fetches a URL and returns the response as a ResultAsync. * * @param url - URL to fetch * @param errorMapper - Function to map fetch errors to custom errors * @returns A ResultAsync containing the response or an error */ function safeFetch(url, errorMapper) { return fromPromise(fetch(url), errorMapper).andThen((response) => { if (!response.ok) { const cause = new Error(`HTTP ${response.status}: ${response.statusText}`); return errAsync(errorMapper(cause)); } return okAsync(response); }); } /** * Parses a GitHub repository URL to extract owner and repository name. * * Supports the following URL formats: * - https://github.com/owner/repo * - https://github.com/owner/repo.git * - git@github.com:owner/repo.git * * @param url - GitHub repository URL * @returns Repository information or null if URL is invalid */ function parseRepositoryUrl(url) { // Handle HTTPS URLs const httpsMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/); if (httpsMatch) { return { owner: httpsMatch[1], repo: httpsMatch[2], }; } // Handle SSH URLs const sshMatch = url.match(/^git@github\.com:([^/]+)\/(.+?)(\.git)?$/); if (sshMatch) { return { owner: sshMatch[1], repo: sshMatch[2], }; } return null; } /** * Creates a new GitHub client for the specified repository. * * This factory function parses the repository URL and returns a Result containing * either a GitHubClient instance or an error if the URL format is invalid. * * The client will automatically use a GitHub token from the GITHUB_TOKEN environment * variable if available, which increases the API rate limit from 60 to 5000 requests/hour. * Note that only the listing operation uses the API; downloads fetch directly from * raw.githubusercontent.com and are not subject to rate limits. * * @param repositoryUrl - GitHub repository URL in one of the following formats: * - https://github.com/owner/repo * - https://github.com/owner/repo.git * - git@github.com:owner/repo.git * @param ref - Git reference (branch, tag, or commit SHA) to use for downloads (default: "master") * @param token - Optional GitHub personal access token. If not provided, will check GITHUB_TOKEN env var * * @returns A Result containing the GitHubClient instance or an InvalidRepositoryUrlError * * @example * import { createGitHubClient } from "./github/client.ts"; * * const clientResult = createGitHubClient("https://github.com/alacritty/alacritty-theme"); * * if (clientResult.isOk()) { * const client = clientResult.data; * * // List all themes in the repository * const themesResult = await client.listThemes() * if (themesResult.isOk()) { * console.log(`Found ${themesResult.data.length} themes`); * } * } else { * console.error("Invalid repository URL:", clientResult.error); * } */ export function createGitHubClient(repositoryUrl, ref = "master", token) { const repoInfo = parseRepositoryUrl(repositoryUrl); if (!repoInfo) { return err(new InvalidRepositoryUrlError(repositoryUrl)); } // Use provided token or fall back to GITHUB_TOKEN environment variable const authToken = token ?? dntShim.Deno.env.get("GITHUB_TOKEN"); return ok(new GitHubClient(repoInfo.owner, repoInfo.repo, ref, authToken)); }