chrome-extension-builder

📁 kjgarza/marketplace-claude 📅 Jan 27, 2026
8
总安装量
8
周安装量
#33985
全站排名
安装命令
npx skills add https://github.com/kjgarza/marketplace-claude --skill chrome-extension-builder

Agent 安装分布

github-copilot 5
gemini-cli 4
codex 4
cursor 4
opencode 3
antigravity 3

Skill 文档

Chrome Extension Builder

Scaffold production-ready Chrome MV3 extensions using WXT + React + shadcn-UI.

Quick Start

pnpm dlx wxt@latest init <project-name> --template react
cd <project-name>
pnpm install
pnpm dev

Workflow

Step 1: Gather Requirements

Ask user about extension type and features:

Component Purpose When to Include
Background Service worker, messaging hub, native messaging Always
Content Script DOM manipulation, page extraction Interacting with web pages
Side Panel Persistent UI alongside pages Complex UIs, suggestion panels
Popup Quick actions, settings access Simple interactions
Options Page Extension configuration User preferences
DevTools Panel Developer debugging tools Development tooling

Step 2: Create Project Structure

/extension
├── entrypoints/
│   ├── background.ts           # Service worker
│   ├── popup/                   # Popup UI (optional)
│   │   ├── index.html
│   │   ├── App.tsx
│   │   └── style.css
│   ├── sidepanel/              # Side panel UI (optional)
│   │   ├── index.html
│   │   ├── App.tsx
│   │   └── style.css
│   └── options/                # Options page (optional)
│       ├── index.html
│       └── App.tsx
├── content/                    # Content scripts
│   ├── main.ts                 # Primary content script
│   └── [site-name].ts          # Site-specific scripts
├── lib/                        # Shared utilities
│   ├── storage.ts              # Storage wrapper
│   ├── messaging.ts            # Message protocol
│   └── types.ts                # Shared types
├── components/                 # React components (shadcn-ui)
│   └── ui/
├── wxt.config.ts              # WXT configuration
├── tailwind.config.js         # Tailwind CSS
├── package.json
└── tsconfig.json

Step 3: Configure WXT

wxt.config.ts:

import { defineConfig } from 'wxt';

export default defineConfig({
  modules: ['@wxt-dev/module-react'],
  manifest: {
    name: 'Extension Name',
    version: '0.1.0',
    permissions: ['storage', 'activeTab', 'scripting'],
    host_permissions: ['https://example.com/*'],
  },
});

See WXT Configuration Reference for complete options.

Step 4: Implement Components

Background Service Worker

// entrypoints/background.ts
export default defineBackground(() => {
  console.log('Extension loaded');

  // Message handler
  browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
    if (message.type === 'GET_DATA') {
      // Handle message
      sendResponse({ success: true, data: {} });
    }
    return true; // Keep channel open for async response
  });
});

Content Script

// content/main.ts
export default defineContentScript({
  matches: ['https://example.com/*'],
  main(ctx) {
    console.log('Content script loaded on', window.location.href);

    // Extract page data
    const pageData = extractPageContent();

    // Send to background
    browser.runtime.sendMessage({ type: 'PAGE_DATA', data: pageData });
  },
});

Side Panel with React

// entrypoints/sidepanel/App.tsx
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';

export default function App() {
  const [data, setData] = useState<DataType[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Listen for messages from background
    browser.runtime.onMessage.addListener((message) => {
      if (message.type === 'NEW_DATA') {
        setData(prev => [message.data, ...prev]);
      }
    });
  }, []);

  const handleAction = async () => {
    setLoading(true);
    const response = await browser.runtime.sendMessage({ type: 'RUN_ACTION' });
    setLoading(false);
  };

  return (
    <div className="p-4">
      <Button onClick={handleAction} disabled={loading}>
        {loading ? 'Processing...' : 'Run Action'}
      </Button>
      {data.map((item) => (
        <Card key={item.id} className="mt-2 p-3">
          {item.content}
        </Card>
      ))}
    </div>
  );
}

Common Patterns

Storage Wrapper

// lib/storage.ts
import { storage } from 'wxt/storage';

export interface DocState {
  docId: string;
  title: string;
  lastRunAt: number;
  items: Item[];
  dismissedIds: string[];
}

const docStateKey = (id: string) => `local:doc:${id}` as const;

export const docStorage = {
  async get(docId: string): Promise<DocState | null> {
    return storage.getItem<DocState>(docStateKey(docId));
  },

  async set(docId: string, state: DocState): Promise<void> {
    await storage.setItem(docStateKey(docId), state);
  },

  watch(docId: string, callback: (state: DocState | null) => void) {
    return storage.watch<DocState>(docStateKey(docId), callback);
  },
};

Message Protocol

// lib/messaging.ts
export const PROTOCOL_VERSION = '1.0.0';

export type MessageType =
  | { type: 'DOC_OPEN'; doc: DocPayload }
  | { type: 'DOC_CHUNK'; docId: string; chunk: string; index: number }
  | { type: 'DOC_DONE'; docId: string }
  | { type: 'SUGGESTIONS'; docId: string; items: Suggestion[] }
  | { type: 'INSERT_FIX'; suggestionId: string }
  | { type: 'ERROR'; code: string; message: string };

export async function sendMessage<T extends MessageType>(
  message: T
): Promise<MessageResponse<T>> {
  return browser.runtime.sendMessage({ ...message, protocolVersion: PROTOCOL_VERSION });
}

Native Messaging (Optional)

// lib/nativeAdapter.ts
export interface NativeAdapter {
  connect(): Promise<void>;
  send(message: unknown): void;
  onMessage(callback: (msg: unknown) => void): void;
  disconnect(): void;
}

export function createNativeAdapter(appName: string): NativeAdapter {
  let port: browser.Runtime.Port | null = null;

  return {
    connect() {
      port = browser.runtime.connectNative(appName);
      return Promise.resolve();
    },
    send(message) {
      port?.postMessage(message);
    },
    onMessage(callback) {
      port?.onMessage.addListener(callback);
    },
    disconnect() {
      port?.disconnect();
      port = null;
    },
  };
}

// Mock adapter for development
export function createMockAdapter(): NativeAdapter {
  return {
    async connect() {},
    send(message) {
      // Simulate response after delay
      setTimeout(() => {
        // Return mock data
      }, 1000);
    },
    onMessage(callback) {},
    disconnect() {},
  };
}

Content Script Insertion

// content/insert.ts
export function insertText(text: string): boolean {
  const selection = window.getSelection();
  if (!selection || selection.rangeCount === 0) return false;

  const range = selection.getRangeAt(0);
  range.deleteContents();
  range.insertNode(document.createTextNode(text));

  // Collapse selection to end
  range.collapse(false);
  selection.removeAllRanges();
  selection.addRange(range);

  return true;
}

// For contenteditable elements (Google Docs workaround)
export async function insertViaClipboard(text: string): Promise<boolean> {
  try {
    await navigator.clipboard.writeText(text);
    document.execCommand('paste');
    return true;
  } catch {
    return false;
  }
}

Shadow DOM UI in Content Scripts

// content/overlay.ts
import { createShadowRootUi } from 'wxt/content-script-ui/shadow-root';
import { createRoot } from 'react-dom/client';
import Overlay from './Overlay';

export default defineContentScript({
  matches: ['https://example.com/*'],
  cssInjectionMode: 'ui',

  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: 'my-overlay',
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        const root = createRoot(container);
        root.render(<Overlay />);
        return root;
      },
      onRemove: (root) => {
        root?.unmount();
      },
    });

    ui.mount();
  },
});

Site-Specific Content Scripts

Google Docs Extraction

// content/gdocs.ts
export default defineContentScript({
  matches: ['https://docs.google.com/document/*'],

  main(ctx) {
    const docId = extractDocId(window.location.href);
    const title = document.title.replace(' - Google Docs', '');

    // Google Docs renders text in .kix-lineview elements
    const lines = document.querySelectorAll('.kix-lineview');
    const text = Array.from(lines)
      .map(el => el.textContent || '')
      .join('\n');

    const cursorContext = getCursorContext();
    const headings = extractHeadings();

    browser.runtime.sendMessage({
      type: 'DOC_OPEN',
      doc: { docId, title, text, cursorContext, headings },
    });
  },
});

function extractDocId(url: string): string {
  const match = url.match(/\/document\/d\/([a-zA-Z0-9-_]+)/);
  return match?.[1] || '';
}

function getCursorContext(): { before: string; after: string } {
  // Implementation depends on Google Docs DOM structure
  return { before: '', after: '' };
}

function extractHeadings(): { text: string; start: number }[] {
  // Extract heading elements
  return [];
}

Overleaf Extraction

// content/overleaf.ts
export default defineContentScript({
  matches: ['https://www.overleaf.com/project/*'],

  main(ctx) {
    const projectId = extractProjectId(window.location.href);

    // Overleaf uses CodeMirror - access editor content
    const editor = document.querySelector('.cm-content');
    const text = editor?.textContent || '';

    browser.runtime.sendMessage({
      type: 'DOC_OPEN',
      doc: { docId: projectId, title: document.title, text },
    });
  },
});

function extractProjectId(url: string): string {
  const match = url.match(/\/project\/([a-f0-9]+)/);
  return match?.[1] || '';
}

UI Setup with shadcn-ui

Initialize Tailwind + shadcn

# After WXT init
pnpm add -D tailwindcss postcss autoprefixer
pnpm dlx tailwindcss init -p

# Add shadcn-ui
pnpm dlx shadcn@latest init
pnpm dlx shadcn@latest add button card toast badge

tailwind.config.js:

/** @type {import('tailwindcss').Config} */
export default {
  darkMode: ['class'],
  content: [
    './entrypoints/**/*.{ts,tsx,html}',
    './components/**/*.{ts,tsx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

Testing

Unit Tests (Vitest)

// lib/__tests__/storage.test.ts
import { describe, it, expect, vi } from 'vitest';
import { docStorage } from '../storage';

vi.mock('wxt/storage', () => ({
  storage: {
    getItem: vi.fn(),
    setItem: vi.fn(),
  },
}));

describe('docStorage', () => {
  it('should get doc state by id', async () => {
    const state = await docStorage.get('doc-123');
    expect(state).toBeDefined();
  });
});

E2E Tests (Playwright)

// e2e/extension.spec.ts
import { test, expect, chromium } from '@playwright/test';

test('extension loads side panel', async () => {
  const pathToExtension = './dist/chrome-mv3';

  const context = await chromium.launchPersistentContext('', {
    headless: false,
    args: [
      `--disable-extensions-except=${pathToExtension}`,
      `--load-extension=${pathToExtension}`,
    ],
  });

  const page = await context.newPage();
  await page.goto('https://docs.google.com/document/d/test');

  // Test content script injection
  // Test side panel interaction
});

Build & Distribution

{
  "scripts": {
    "dev": "wxt",
    "dev:firefox": "wxt -b firefox",
    "build": "wxt build",
    "build:firefox": "wxt build -b firefox",
    "zip": "wxt zip",
    "test": "vitest",
    "lint": "eslint ."
  }
}

Environment Variables:

# .env.development
USE_MOCK_NATIVE=true

# .env.production
USE_MOCK_NATIVE=false

Reference Files

Assets