feat(browser): Add TypeScript support and configuration for Repomix extension

This commit is contained in:
Kazuki Yamada
2025-05-24 15:46:46 +09:00
parent 8c36dfecf4
commit 3af93b9b21
13 changed files with 2110 additions and 275 deletions

View File

@@ -6,6 +6,7 @@
"./src/**",
"./tests/**",
"./website/**",
"./browser/**",
"./.devcontainer/**",
"./.github/**",
"package.json",
@@ -20,7 +21,9 @@
"website/client/.vitepress/.temp",
"website/client/.vitepress/dist",
"website/client/.vitepress/cache",
"website/server/dist"
"website/server/dist",
"browser/dist",
"browser/packages"
]
},
"organizeImports": {

View File

@@ -1,9 +1,7 @@
# Repomix
# Repomix Extension
A browser extension that adds a Repomix button to GitHub repository pages.
![Repomix Button Demo](https://via.placeholder.com/800x400/f0f0f0/333333?text=Repomix+Button+Demo)
## 🚀 Features
- Adds a "Repomix" button to GitHub repository pages
@@ -79,12 +77,3 @@ This extension:
- Does not track user behavior
- Only accesses github.com
- Requires minimal permissions
## 📄 License
MIT License
## 🙋‍♂️ Related Projects
- [Repomix](https://github.com/yamadashy/repomix) - AI-friendly repository packing tool
- [Repomix Website](https://repomix.com) - Online version of Repomix

View File

@@ -1,65 +0,0 @@
// This background.js is kept minimal as all implementation is handled in content_scripts
const injectContentToTab = async (tab) => {
// Skip if URL is undefined
if (!tab.url) {
return;
}
// Skip if tab is discarded
if (tab.discarded) {
return;
}
// Skip if tab ID is undefined
if (tab.id === undefined) {
return;
}
// Skip if not a GitHub URL
if (!tab.url.startsWith('https://github.com/')) {
return;
}
try {
const manifest = chrome.runtime.getManifest();
// Inject CSS
if (manifest.content_scripts && manifest.content_scripts[0].css) {
await chrome.scripting.insertCSS({
target: { tabId: tab.id },
files: manifest.content_scripts[0].css
});
}
// Inject JavaScript
if (manifest.content_scripts && manifest.content_scripts[0].js) {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: manifest.content_scripts[0].js
});
}
} catch (e) {
// Ignore errors (e.g., chrome:// pages, extension pages, etc.)
}
};
// Apply content to all tabs when installed or updated
chrome.runtime.onInstalled.addListener(() => {
chrome.tabs.query({}, async (tabs) => {
for (const tab of tabs) {
try {
await injectContentToTab(tab);
} catch (e) {
console.error('Failed to inject content to tab:', e);
}
}
});
});
// Apply content when a new tab is created
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete') {
injectContentToTab(tab);
}
});

View File

@@ -0,0 +1,74 @@
// This background.ts is kept minimal as all implementation is handled in content_scripts
const injectContentToTab = async (tab: chrome.tabs.Tab): Promise<void> => {
// Skip if URL is undefined
if (!tab.url) {
return;
}
// Skip if tab is discarded
if (tab.discarded) {
return;
}
// Skip if tab ID is undefined
if (tab.id === undefined) {
return;
}
// Skip if not a GitHub URL
if (!tab.url.startsWith('https://github.com/')) {
return;
}
try {
const manifest = chrome.runtime.getManifest();
// Inject CSS
if (manifest.content_scripts?.[0]?.css) {
await chrome.scripting.insertCSS({
target: { tabId: tab.id },
files: manifest.content_scripts[0].css,
});
}
// Inject JavaScript
if (manifest.content_scripts?.[0]?.js) {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: manifest.content_scripts[0].js,
});
}
} catch (error) {
console.error('Error injecting content script:', error);
}
};
// Handle installation
chrome.runtime.onInstalled.addListener(async (): Promise<void> => {
console.log('Repomix extension installed');
try {
// Get all GitHub tabs
const tabs = await chrome.tabs.query({
url: 'https://github.com/*',
});
// Inject content script to existing GitHub tabs
for (const tab of tabs) {
await injectContentToTab(tab);
}
} catch (error) {
console.error('Error during installation:', error);
}
});
// Handle tab updates
chrome.tabs.onUpdated.addListener(
async (tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise<void> => {
// Only inject when page is completely loaded
if (changeInfo.status === 'complete') {
await injectContentToTab(tab);
}
},
);

View File

@@ -1,95 +0,0 @@
// Function to add Repomix button
function addRepomixButton() {
// If button already exists, do nothing
if (document.querySelector('.repomix-button')) {
return;
}
// Get repository main navigation element
const navActions = document.querySelector('ul.pagehead-actions');
if (!navActions) {
return;
}
// Get repository information from current URL
const pathMatch = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/);
if (!pathMatch) {
return;
}
const [, owner, repo] = pathMatch;
const repoUrl = `https://github.com/${owner}/${repo}`;
const repomixUrl = `https://repomix.com/?repo=${encodeURIComponent(repoUrl)}`;
// Create Repomix button container
const container = document.createElement('li');
// Create BtnGroup container
const btnGroup = document.createElement('div');
btnGroup.setAttribute('data-view-component', 'true');
btnGroup.className = 'BtnGroup';
// Create button
const button = document.createElement('a');
button.href = repomixUrl;
button.target = '_blank';
button.rel = 'noopener noreferrer';
button.className = 'repomix-button btn-sm btn BtnGroup-item';
button.setAttribute('data-view-component', 'true');
// Add icon
const icon = document.createElement('span');
icon.className = 'octicon';
icon.innerHTML = `<img src="${chrome.runtime.getURL('images/icon-64.png')}" width="16" height="16" alt="Repomix">`;
// Add text with i18n support
const text = document.createTextNode(' Repomix');
button.appendChild(icon);
button.appendChild(text);
btnGroup.appendChild(button);
container.appendChild(btnGroup);
// Add to navigation
navActions.insertBefore(container, navActions.firstChild);
}
// Execute immediately and on DOMContentLoaded
addRepomixButton();
document.addEventListener('DOMContentLoaded', () => {
addRepomixButton();
});
// Handle GitHub SPA navigation
let lastUrl = location.href;
const observer = new MutationObserver((mutations) => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
setTimeout(() => {
addRepomixButton();
}, 100);
}
// Monitor navigation element addition (only if button doesn't exist)
mutations.forEach((mutation) => {
const repomixButton = document.querySelector('.repomix-button');
if (!repomixButton && mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const navActions = node.querySelector ? node.querySelector('ul.pagehead-actions') : null;
if (navActions || (node.matches && node.matches('ul.pagehead-actions'))) {
setTimeout(() => {
addRepomixButton();
}, 50);
}
}
});
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});

View File

@@ -0,0 +1,145 @@
interface RepositoryInfo {
owner: string;
repo: string;
url: string;
}
interface RepomixButtonOptions {
text: string;
href: string;
iconSrc?: string;
}
// Constants
const BUTTON_CLASS = 'repomix-button';
const ICON_SIZE = 16;
const REPOMIX_BASE_URL = 'https://repomix.com';
const BUTTON_TEXT = 'Repomix';
const DEFAULT_ICON_PATH = 'images/icon-64.png';
// Button functions
function isRepomixButtonAlreadyExists(): boolean {
return document.querySelector(`.${BUTTON_CLASS}`) !== null;
}
function createRepomixButton(options: RepomixButtonOptions): HTMLElement {
const container = document.createElement('li');
const button = document.createElement('a');
button.className = `${BUTTON_CLASS} btn-sm btn BtnGroup-item`;
button.href = options.href;
button.target = '_blank';
button.rel = 'noopener noreferrer';
button.title = 'Open with Repomix';
// Create octicon container
const octicon = document.createElement('span');
octicon.className = 'octicon';
octicon.setAttribute('aria-hidden', 'true');
// Use chrome.runtime.getURL for the icon
const iconSrc = options.iconSrc || chrome.runtime.getURL(DEFAULT_ICON_PATH);
octicon.innerHTML = `<img src="${iconSrc}" width="${ICON_SIZE}" height="${ICON_SIZE}" alt="Repomix">`;
button.appendChild(octicon);
// Add button text
const textSpan = document.createElement('span');
textSpan.textContent = options.text;
button.appendChild(textSpan);
container.appendChild(button);
return container;
}
// GitHub functions
function extractRepositoryInfo(): RepositoryInfo | null {
const pathMatch = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/);
if (!pathMatch) {
return null;
}
const [, owner, repo] = pathMatch;
return {
owner,
repo,
url: `https://github.com/${owner}/${repo}`,
};
}
function findNavigationContainer(): Element | null {
return document.querySelector('ul.pagehead-actions');
}
function isRepositoryPage(): boolean {
// Check if we're on a repository page (not user profile, organization, etc.)
const pathParts = window.location.pathname.split('/').filter(Boolean);
return pathParts.length >= 2 && !pathParts[0].startsWith('@');
}
// Main integration functions
function addRepomixButton(): void {
// Check if button already exists
if (isRepomixButtonAlreadyExists()) {
return;
}
// Get repository information
const repoInfo = extractRepositoryInfo();
if (!repoInfo) {
return;
}
// Find navigation container
const navContainer = findNavigationContainer();
if (!navContainer) {
return;
}
// Create Repomix URL
const repomixUrl = `${REPOMIX_BASE_URL}/?repo=${encodeURIComponent(repoInfo.url)}`;
// Create button
const buttonContainer = createRepomixButton({
text: BUTTON_TEXT,
href: repomixUrl,
});
// Insert button at the beginning (left side)
navContainer.prepend(buttonContainer);
console.log(`Repomix button added for ${repoInfo.owner}/${repoInfo.repo}`);
}
function observePageChanges(): void {
// Observe changes to handle GitHub's dynamic navigation
const observer = new MutationObserver(() => {
addRepomixButton();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Also listen for popstate events (back/forward navigation)
window.addEventListener('popstate', () => {
setTimeout(() => addRepomixButton(), 100);
});
}
function initRepomixIntegration(): void {
if (!isRepositoryPage()) {
return;
}
addRepomixButton();
observePageChanges();
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initRepomixIntegration());
} else {
initRepomixIntegration();
}

1786
browser/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
{
"private": true,
"name": "repomix",
"version": "1.0.0",
"description": "A browser extension that adds a Repomix button to GitHub repositories",
"scripts": {
"dev": "webextension-toolbox dev",
"build": "webextension-toolbox build",
"build-all": "npm run build chrome && npm run build firefox && npm run build edge",
"generate-icons": "node scripts/generate-icons.js"
"generate-icons": "tsx scripts/generate-icons.ts",
"lint": "npm run lint-tsc",
"lint-tsc": "tsc --noEmit",
"test": "vitest",
"archive": "git archive HEAD -o storage/source.zip"
},
"keywords": [
"chrome",
@@ -21,7 +24,21 @@
"author": "yamadashy",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@secretlint/secretlint-rule-preset-recommend": "^9.3.1",
"@types/chrome": "^0.0.323",
"@types/node": "^22.10.2",
"@types/webextension-polyfill": "^0.10.7",
"@webextension-toolbox/webextension-toolbox": "^7.1.1",
"sharp": "^0.34.1"
"secretlint": "^9.3.1",
"sharp": "^0.34.1",
"tsx": "^4.19.2",
"typescript": "^5.8.3"
},
"browserslist": [
"last 2 versions, not dead, > 0.2%"
],
"engines": {
"node": ">=24.0.1"
}
}

View File

@@ -1,24 +0,0 @@
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
const sizes = [16, 19, 32, 38, 48, 64, 128];
const inputSvg = path.join(__dirname, 'app/images/icon.svg');
const outputDir = path.join(__dirname, 'app/images');
// Create output directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Generate PNG files for each size
sizes.forEach(size => {
sharp(inputSvg)
.resize(size, size)
.png()
.toFile(path.join(outputDir, `icon-${size}.png`))
.then(() => console.log(`Generated ${size}x${size} icon`))
.catch(err => console.error(`Error generating ${size}x${size} icon:`, err));
});
console.log('Icon generation started...');

View File

@@ -0,0 +1,77 @@
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
interface IconSize {
width: number;
height: number;
}
const ICON_SIZES: readonly number[] = [16, 19, 32, 38, 48, 64, 128] as const;
const INPUT_SVG_PATH = path.join(__dirname, '../app/images/icon.svg');
const OUTPUT_DIR = path.join(__dirname, '../app/images');
/**
* Ensures the output directory exists
*/
function ensureOutputDirectory(): void {
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
console.log(`Created output directory: ${OUTPUT_DIR}`);
}
}
/**
* Generates a PNG icon of the specified size
*/
async function generateIcon(size: number): Promise<void> {
try {
const outputPath = path.join(OUTPUT_DIR, `icon-${size}.png`);
await sharp(INPUT_SVG_PATH).resize(size, size).png().toFile(outputPath);
console.log(`✅ Generated ${size}x${size} icon: ${outputPath}`);
} catch (error) {
console.error(`❌ Error generating ${size}x${size} icon:`, error);
throw error;
}
}
/**
* Validates that the input SVG file exists
*/
function validateInputFile(): void {
if (!fs.existsSync(INPUT_SVG_PATH)) {
throw new Error(`Input SVG file not found: ${INPUT_SVG_PATH}`);
}
console.log(`📁 Input SVG: ${INPUT_SVG_PATH}`);
}
/**
* Main function to generate all icon sizes
*/
async function generateAllIcons(): Promise<void> {
console.log('🚀 Starting icon generation...');
try {
validateInputFile();
ensureOutputDirectory();
// Generate all icons in parallel
const iconPromises = ICON_SIZES.map((size) => generateIcon(size));
await Promise.all(iconPromises);
console.log(`🎉 Successfully generated ${ICON_SIZES.length} icons!`);
console.log(`📂 Output directory: ${OUTPUT_DIR}`);
} catch (error) {
console.error('💥 Failed to generate icons:', error);
process.exit(1);
}
}
// Execute if this file is run directly
if (require.main === module) {
void generateAllIcons();
}
export { generateAllIcons, generateIcon, ICON_SIZES };

View File

@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it } from 'vitest';
// Mock DOM environment
Object.defineProperty(window, 'location', {
value: {
pathname: '/yamadashy/repomix',
href: 'https://github.com/yamadashy/repomix',
},
writable: true,
});
describe('RepomixIntegration', () => {
beforeEach(() => {
// Reset DOM
document.body.innerHTML = '';
// Mock GitHub page structure
const navActions = document.createElement('ul');
navActions.className = 'pagehead-actions';
document.body.appendChild(navActions);
});
it('should extract repository information correctly', () => {
// This is a placeholder test since we're testing static methods
// In a real scenario, we'd need to import and test the actual classes
const pathMatch = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/);
expect(pathMatch).toBeTruthy();
if (pathMatch) {
const [, owner, repo] = pathMatch;
expect(owner).toBe('yamadashy');
expect(repo).toBe('repomix');
}
});
it('should construct correct Repomix URL', () => {
const repoUrl = 'https://github.com/yamadashy/repomix';
const expectedUrl = `https://repomix.com/?repo=${encodeURIComponent(repoUrl)}`;
expect(expectedUrl).toBe('https://repomix.com/?repo=https%3A%2F%2Fgithub.com%2Fyamadashy%2Frepomix');
});
it('should find navigation container', () => {
const navContainer = document.querySelector('ul.pagehead-actions');
expect(navContainer).toBeTruthy();
});
});

18
browser/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"target": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"lib": [
"dom",
"esnext"
],
"allowJs": false,
"noImplicitAny": true,
"removeComments": true,
"skipLibCheck": true,
"sourceMap": true
}
}

13
browser/vitest.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: [],
watch: false,
},
resolve: {
extensions: ['.ts', '.js'],
},
});