A browser extension that lets you summarize any webpage and ask questions using AI.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial commit of working extension, most basic features

Elliot Hopkins 129c128a

+2401
.DS_Store

This is a binary file and will not be displayed.

+153
README.md
··· 1 + # WebAI Summarizer 2 + 3 + A minimalist Chrome extension that lets you ask questions and summarize any webpage using AI (Ollama or any OpenAI-compatible API). 4 + 5 + ## Features 6 + 7 + - 🤖 **AI-Powered Chat** - Ask questions about the current webpage 8 + - 📄 **One-Click Summarize** - Instantly summarize page content 9 + - 🔌 **OpenAI-Compatible** - Works with Ollama, OpenAI, Groq, LM Studio, and more 10 + - ⚙️ **Configurable** - Choose your own model and API endpoint 11 + - 🌙 **Dark Theme** - Easy on the eyes 12 + - 🔒 **Private** - All processing happens locally with Ollama (optional) 13 + 14 + ## Installation 15 + 16 + ### 1. Load as Unpacked Extension 17 + 18 + 1. Open Chrome and navigate to `chrome://extensions/` 19 + 2. Enable **Developer mode** (toggle in top right) 20 + 3. Click **Load unpacked** 21 + 4. Select the `webai-summarizer` folder 22 + 5. The extension icon should appear in your toolbar 23 + 24 + ### 2. Install and Configure Ollama (Recommended) 25 + 26 + ```bash 27 + # Install Ollama (macOS) 28 + brew install ollama 29 + 30 + # Pull a model 31 + ollama pull gemma3:1b 32 + 33 + # Start Ollama server (keep this running) 34 + ollama serve 35 + ``` 36 + 37 + ### 3. Configure Settings 38 + 39 + 1. Click the extension icon 40 + 2. Click the **⚙️ Settings** gear 41 + 3. Configure the following: 42 + 43 + #### For Ollama Native Mode (Recommended): 44 + ``` 45 + API Mode: Ollama Native 46 + API Base URL: http://localhost:11434 47 + Model: gemma3:1b (or your model name) 48 + API Key: (leave empty) 49 + ``` 50 + 51 + #### For OpenAI-Compatible Mode: 52 + ``` 53 + API Mode: OpenAI-Compatible 54 + API Base URL: http://localhost:11434/v1 (note the /v1) 55 + Model: llama3.2 56 + API Key: (leave empty for local Ollama) 57 + ``` 58 + 59 + 4. Click **Test Connection** to verify 60 + 61 + ## Usage 62 + 63 + 1. **Navigate to any webpage** 64 + 2. **Click the extension icon** in your toolbar 65 + 3. **Click "Summarize Page"** for an instant summary, **or** 66 + 4. **Type a question** about the page content and press Enter 67 + 68 + ## API Mode Explained 69 + 70 + ### Ollama Native Mode 71 + - Uses Ollama's native `/api/generate` endpoint 72 + - **No CORS issues** - works out of the box 73 + - Recommended for local Ollama installations 74 + - Base URL should NOT include `/v1` 75 + 76 + ### OpenAI-Compatible Mode 77 + - Uses the `/v1/chat/completions` endpoint 78 + - Required for OpenAI, Groq, and other cloud providers 79 + - Can work with Ollama if you set the `OPENAI_API_BASE` environment variable 80 + - Base URL MUST include `/v1` for Ollama compatibility mode 81 + 82 + ## Supported APIs 83 + 84 + | Service | API Mode | Base URL | API Key | 85 + |---------|----------|----------|---------| 86 + | **Ollama** (local) | Native | `http://localhost:11434` | No | 87 + | **Ollama** (OpenAI format) | OpenAI-Compatible | `http://localhost:11434/v1` | No | 88 + | **OpenAI** | OpenAI-Compatible | `https://api.openai.com/v1` | Yes | 89 + | **Groq** | OpenAI-Compatible | `https://api.groq.com/openai/v1` | Yes | 90 + | **LM Studio** | OpenAI-Compatible | `http://localhost:1234/v1` | No | 91 + 92 + ## Troubleshooting 93 + 94 + ### "Cannot connect to localhost:11434" 95 + 1. Make sure Ollama is running: `ollama serve` 96 + 2. Check that you pulled the model: `ollama list` 97 + 3. If using Ollama, make sure your URL doesn't have `/v1` in Native mode 98 + 4. Check browser console for detailed error messages 99 + 100 + ### "HTTP 403" or "HTTP 405" 101 + This means the API endpoint is wrong. Try: 102 + 1. Switch API Mode from settings 103 + 2. For Ollama Native: use `http://localhost:11434` (no /v1) 104 + 3. For OpenAI mode: use `http://localhost:11434/v1` (with /v1) 105 + 106 + ### "Model not found" 107 + ```bash 108 + # Pull the model first 109 + ollama pull llama3.2 110 + 111 + # Verify it's available 112 + ollama list 113 + ``` 114 + 115 + ### Extension not appearing 116 + - Make sure Developer mode is enabled 117 + - Try refreshing the extensions page 118 + 119 + ## File Structure 120 + 121 + ``` 122 + webai-summarizer/ 123 + ├── manifest.json # Extension configuration 124 + ├── popup/ 125 + │ ├── popup.html # Chat interface 126 + │ ├── popup.css # Styling 127 + │ └── popup.js # Popup logic 128 + ├── scripts/ 129 + │ ├── content.js # Webpage text extraction 130 + │ └── background.js # API communication 131 + ├── options/ 132 + │ ├── options.html # Settings page 133 + │ ├── options.css 134 + │ └── options.js 135 + └── icons/ 136 + ├── icon16.png 137 + ├── icon32.png 138 + ├── icon48.png 139 + └── icon128.png 140 + ``` 141 + 142 + ## Future Improvements 143 + 144 + - [ ] Streaming responses 145 + - [ ] Conversation history persistence 146 + - [ ] Keyboard shortcuts 147 + - [ ] Context menu integration 148 + - [ ] PDF support 149 + - [ ] Chrome Web Store publishing 150 + 151 + ## License 152 + 153 + Personal use - feel free to modify and extend!
_metadata/generated_indexed_rulesets/_ruleset1

This is a binary file and will not be displayed.

+20
icons/create_icons.sh
··· 1 + #!/bin/bash 2 + # This script creates simple icon files for testing 3 + # In production, replace with proper designed icons 4 + 5 + # Using ImageMagick if available, otherwise manual creation 6 + if command -v convert &> /dev/null; then 7 + # Create a simple colored square icon 8 + for size in 16 32 48 128; do 9 + convert -size ${size}x${size} xc:'#e94560' -pointsize $((size/3)) -fill white -gravity center -annotate +0+0 "AI" "icon${size}.png" 10 + done 11 + echo "Icons created with ImageMagick" 12 + else 13 + echo "ImageMagick not found. Please manually create icons:" 14 + echo "- icon16.png (16x16)" 15 + echo "- icon32.png (32x32)" 16 + echo "- icon48.png (48x48)" 17 + echo "- icon128.png (128x128)" 18 + echo "" 19 + echo "Or install ImageMagick: brew install imagemagick (macOS)" 20 + fi
icons/icon128.png

This is a binary file and will not be displayed.

icons/icon16.png

This is a binary file and will not be displayed.

icons/icon32.png

This is a binary file and will not be displayed.

icons/icon48.png

This is a binary file and will not be displayed.

+53
manifest.json
··· 1 + { 2 + "manifest_version": 3, 3 + "name": "WebAI Summarizer", 4 + "version": "1.0.0", 5 + "description": "Ask questions and summarize any webpage with AI", 6 + "permissions": [ 7 + "activeTab", 8 + "tabs", 9 + "storage", 10 + "scripting", 11 + "declarativeNetRequest", 12 + "contextMenus" 13 + ], 14 + "host_permissions": [ 15 + "http://localhost/*", 16 + "http://*/*", 17 + "https://*/*" 18 + ], 19 + "declarative_net_request": { 20 + "rule_resources": [ 21 + { 22 + "id": "rewrite_origin", 23 + "enabled": true, 24 + "path": "rules.json" 25 + } 26 + ] 27 + }, 28 + "action": { 29 + "default_popup": "popup/popup.html", 30 + "default_icon": { 31 + "16": "icons/icon16.png", 32 + "32": "icons/icon32.png", 33 + "48": "icons/icon48.png", 34 + "128": "icons/icon128.png" 35 + } 36 + }, 37 + "background": { 38 + "service_worker": "scripts/background.js" 39 + }, 40 + "content_scripts": [ 41 + { 42 + "matches": ["<all_urls>"], 43 + "js": ["scripts/content.js"] 44 + } 45 + ], 46 + "options_page": "options/options.html", 47 + "icons": { 48 + "16": "icons/icon16.png", 49 + "32": "icons/icon32.png", 50 + "48": "icons/icon48.png", 51 + "128": "icons/icon128.png" 52 + } 53 + }
+244
options/options.css
··· 1 + * { 2 + margin: 0; 3 + padding: 0; 4 + box-sizing: border-box; 5 + } 6 + 7 + body { 8 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 9 + background: #f5f0e8; 10 + color: #1a1a1a; 11 + line-height: 1.6; 12 + padding: 40px 20px; 13 + } 14 + 15 + .container { 16 + max-width: 560px; 17 + margin: 0 auto; 18 + } 19 + 20 + /* ── Header ── */ 21 + .page-header { 22 + display: flex; 23 + align-items: center; 24 + gap: 8px; 25 + margin-bottom: 32px; 26 + } 27 + 28 + .page-header .logo-mark { 29 + font-size: 14px; 30 + color: #999; 31 + } 32 + 33 + .page-header h1 { 34 + font-size: 15px; 35 + font-weight: 600; 36 + color: #1a1a1a; 37 + letter-spacing: 0.01em; 38 + } 39 + 40 + /* ── Form ── */ 41 + .form-group { 42 + margin-bottom: 20px; 43 + } 44 + 45 + label { 46 + display: block; 47 + font-size: 12px; 48 + font-weight: 600; 49 + color: #555; 50 + text-transform: uppercase; 51 + letter-spacing: 0.05em; 52 + margin-bottom: 6px; 53 + } 54 + 55 + input[type="text"], 56 + input[type="url"], 57 + input[type="password"], 58 + textarea, 59 + select { 60 + width: 100%; 61 + padding: 9px 12px; 62 + border: 1px solid #e0d8cc; 63 + border-radius: 6px; 64 + background: #fff; 65 + color: #1a1a1a; 66 + font-size: 13.5px; 67 + font-family: inherit; 68 + transition: border-color 0.1s; 69 + } 70 + 71 + input:focus, 72 + textarea:focus, 73 + select:focus { 74 + outline: none; 75 + border-color: #aaa; 76 + } 77 + 78 + select { 79 + cursor: pointer; 80 + appearance: none; 81 + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23aaa' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); 82 + background-repeat: no-repeat; 83 + background-position: right 10px center; 84 + background-size: 14px; 85 + padding-right: 32px; 86 + } 87 + 88 + textarea { 89 + resize: vertical; 90 + min-height: 80px; 91 + } 92 + 93 + .help { 94 + font-size: 11.5px; 95 + color: #aaa; 96 + margin-top: 5px; 97 + line-height: 1.4; 98 + } 99 + 100 + .help code { 101 + background: #ede8de; 102 + padding: 1px 5px; 103 + border-radius: 3px; 104 + font-family: 'SF Mono', 'Cascadia Code', monospace; 105 + font-size: 11px; 106 + color: #555; 107 + } 108 + 109 + /* ── Buttons ── */ 110 + .buttons { 111 + display: flex; 112 + gap: 8px; 113 + margin-top: 24px; 114 + align-items: center; 115 + } 116 + 117 + button { 118 + font-family: inherit; 119 + cursor: pointer; 120 + transition: all 0.1s; 121 + } 122 + 123 + .btn-primary { 124 + padding: 8px 18px; 125 + background: #1a1a1a; 126 + color: #f5f0e8; 127 + border: none; 128 + border-radius: 6px; 129 + font-size: 12.5px; 130 + font-weight: 500; 131 + } 132 + 133 + .btn-primary:hover { background: #333; } 134 + 135 + .btn-secondary { 136 + padding: 8px 14px; 137 + background: transparent; 138 + color: #777; 139 + border: 1px solid #e0d8cc; 140 + border-radius: 6px; 141 + font-size: 12.5px; 142 + font-weight: 500; 143 + } 144 + 145 + .btn-secondary:hover { 146 + color: #333; 147 + border-color: #bbb; 148 + } 149 + 150 + .btn-link { 151 + padding: 8px 4px; 152 + background: transparent; 153 + border: none; 154 + color: #bbb; 155 + font-size: 12px; 156 + text-decoration: underline; 157 + } 158 + 159 + .btn-link:hover { color: #777; } 160 + 161 + /* ── Status ── */ 162 + .status { 163 + margin-top: 14px; 164 + padding: 10px 14px; 165 + border-radius: 6px; 166 + font-size: 12.5px; 167 + display: none; 168 + line-height: 1.5; 169 + } 170 + 171 + .status.success { 172 + display: block; 173 + background: #f0faf0; 174 + color: #3a7a3a; 175 + border: 1px solid #c8e8c8; 176 + } 177 + 178 + .status.error { 179 + display: block; 180 + background: #fdf0f0; 181 + color: #c05050; 182 + border: 1px solid #f0d8d8; 183 + } 184 + 185 + .status.loading { 186 + display: block; 187 + background: #faf8f4; 188 + color: #aaa; 189 + border: 1px solid #e0d8cc; 190 + } 191 + 192 + /* ── Divider ── */ 193 + .divider { 194 + border: none; 195 + border-top: 1px solid #e0d8cc; 196 + margin: 28px 0; 197 + } 198 + 199 + /* ── Info section ── */ 200 + .info-section h2 { 201 + font-size: 12px; 202 + font-weight: 600; 203 + color: #555; 204 + text-transform: uppercase; 205 + letter-spacing: 0.05em; 206 + margin-bottom: 10px; 207 + } 208 + 209 + .info-section + .info-section { 210 + margin-top: 20px; 211 + } 212 + 213 + .info-section p { 214 + font-size: 13px; 215 + color: #666; 216 + margin-bottom: 8px; 217 + } 218 + 219 + .info-section ol, 220 + .info-section ul { 221 + margin-left: 18px; 222 + font-size: 13px; 223 + color: #666; 224 + } 225 + 226 + .info-section li { 227 + margin-bottom: 5px; 228 + } 229 + 230 + .info-section code { 231 + background: #ede8de; 232 + padding: 1px 5px; 233 + border-radius: 3px; 234 + font-family: 'SF Mono', 'Cascadia Code', monospace; 235 + font-size: 11px; 236 + color: #555; 237 + } 238 + 239 + .info-section a { 240 + color: #555; 241 + text-decoration: underline; 242 + } 243 + 244 + .info-section a:hover { color: #111; }
+122
options/options.html
··· 1 + <!doctype html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <title>Summarize — Settings</title> 6 + <link rel="stylesheet" href="options.css" /> 7 + </head> 8 + <body> 9 + <div class="container"> 10 + <div class="page-header"> 11 + <div class="logo-mark">✦</div> 12 + <h1>Settings</h1> 13 + </div> 14 + 15 + <form id="settings-form"> 16 + <div class="form-group"> 17 + <label for="api-mode">API Mode</label> 18 + <select id="api-mode"> 19 + <option value="ollama">Ollama (local)</option> 20 + <option value="openai">OpenAI-compatible</option> 21 + </select> 22 + <p class="help"> 23 + Use <strong>Ollama</strong> for local models. Use 24 + <strong>OpenAI-compatible</strong> for OpenAI, Groq, LM 25 + Studio, etc. 26 + </p> 27 + </div> 28 + 29 + <div class="form-group"> 30 + <label for="api-base-url">API Base URL</label> 31 + <input 32 + type="url" 33 + id="api-base-url" 34 + placeholder="http://localhost:11434" 35 + /> 36 + <p class="help"> 37 + Ollama: 38 + <code>http://localhost:11434</code> &nbsp;·&nbsp; 39 + OpenAI: <code>https://api.openai.com/v1</code> 40 + </p> 41 + </div> 42 + 43 + <div class="form-group"> 44 + <label for="model">Model</label> 45 + <input 46 + type="text" 47 + id="model" 48 + placeholder="gpt-oss:20b-cloud" 49 + /> 50 + <p class="help"> 51 + Examples: <code>gemma3:1b</code>, <code>llama3.2</code>, 52 + <code>gpt-4o-mini</code> 53 + </p> 54 + </div> 55 + 56 + <div class="form-group"> 57 + <label for="api-key" 58 + >API Key 59 + <span 60 + style=" 61 + font-weight: 400; 62 + text-transform: none; 63 + letter-spacing: 0; 64 + " 65 + >(optional)</span 66 + ></label 67 + > 68 + <input type="password" id="api-key" placeholder="sk-…" /> 69 + <p class="help"> 70 + Required for OpenAI / Groq. Leave blank for local 71 + Ollama. 72 + </p> 73 + </div> 74 + 75 + <div class="buttons"> 76 + <button type="submit" class="btn-primary">Save</button> 77 + <button 78 + type="button" 79 + id="test-connection" 80 + class="btn-secondary" 81 + > 82 + Test connection 83 + </button> 84 + <button type="button" id="reset-defaults" class="btn-link"> 85 + Reset defaults 86 + </button> 87 + </div> 88 + </form> 89 + 90 + <div id="status" class="status"></div> 91 + 92 + <hr class="divider" /> 93 + 94 + <div class="info-section"> 95 + <h2>Quick start with Ollama</h2> 96 + <ol> 97 + <li> 98 + Install Ollama from 99 + <a href="https://ollama.com" target="_blank" 100 + >ollama.com</a 101 + > 102 + </li> 103 + <li>Pull a model: <code>ollama pull gemma3:1b</code></li> 104 + <li>Run: <code>OLLAMA_ORIGINS=* ollama serve</code></li> 105 + <li>Keep default settings and click "Test connection"</li> 106 + </ol> 107 + </div> 108 + 109 + <div class="info-section"> 110 + <h2>Supported providers</h2> 111 + <ul> 112 + <li><strong>Ollama</strong> — local, private, free</li> 113 + <li><strong>OpenAI</strong> — GPT-4o, GPT-4o mini</li> 114 + <li><strong>Groq</strong> — fast cloud inference</li> 115 + <li><strong>LM Studio</strong> — local GUI runner</li> 116 + </ul> 117 + </div> 118 + </div> 119 + 120 + <script src="options.js"></script> 121 + </body> 122 + </html>
+143
options/options.js
··· 1 + // Options page script 2 + 3 + const form = document.getElementById("settings-form"); 4 + const apiModeInput = document.getElementById("api-mode"); 5 + const apiBaseUrlInput = document.getElementById("api-base-url"); 6 + const modelInput = document.getElementById("model"); 7 + const apiKeyInput = document.getElementById("api-key"); 8 + const statusDiv = document.getElementById("status"); 9 + const testBtn = document.getElementById("test-connection"); 10 + const resetBtn = document.getElementById("reset-defaults"); 11 + 12 + const defaultSettings = { 13 + apiMode: "ollama", 14 + apiBaseUrl: "http://localhost:11434", 15 + model: "gpt-oss:20b-cloud", 16 + apiKey: "", 17 + }; 18 + 19 + // Load settings on page load 20 + document.addEventListener("DOMContentLoaded", loadSettings); 21 + 22 + // Update URL placeholder when mode changes 23 + apiModeInput.addEventListener("change", () => { 24 + if (apiModeInput.value === "ollama") { 25 + apiBaseUrlInput.placeholder = "http://localhost:11434"; 26 + } else { 27 + apiBaseUrlInput.placeholder = "http://localhost:11434/v1"; 28 + } 29 + }); 30 + 31 + // Save settings 32 + form.addEventListener("submit", async (e) => { 33 + e.preventDefault(); 34 + 35 + const settings = { 36 + apiMode: apiModeInput.value, 37 + apiBaseUrl: apiBaseUrlInput.value.trim() || defaultSettings.apiBaseUrl, 38 + model: modelInput.value.trim() || defaultSettings.model, 39 + apiKey: apiKeyInput.value.trim(), 40 + }; 41 + 42 + try { 43 + await chrome.storage.sync.set(settings); 44 + showStatus("✅ Settings saved successfully!", "success"); 45 + } catch (error) { 46 + showStatus("❌ Error saving settings: " + error.message, "error"); 47 + } 48 + }); 49 + 50 + // Test connection 51 + testBtn.addEventListener("click", async () => { 52 + const settings = { 53 + apiMode: apiModeInput.value, 54 + apiBaseUrl: apiBaseUrlInput.value.trim() || defaultSettings.apiBaseUrl, 55 + model: modelInput.value.trim() || defaultSettings.model, 56 + apiKey: apiKeyInput.value.trim(), 57 + }; 58 + 59 + showStatus("🔄 Testing connection...", "loading"); 60 + 61 + try { 62 + // Use background script to test connection (avoids CORS) 63 + const response = await chrome.runtime.sendMessage({ 64 + action: "chat", 65 + data: { 66 + apiBaseUrl: settings.apiBaseUrl, 67 + model: settings.model, 68 + apiKey: settings.apiKey, 69 + messages: [ 70 + { role: "system", content: "You are a helpful assistant." }, 71 + { role: "user", content: 'Say "Connection successful!"' }, 72 + ], 73 + apiMode: settings.apiMode, 74 + }, 75 + }); 76 + 77 + if (response.success) { 78 + const content = 79 + response.data.choices?.[0]?.message?.content || 80 + response.data.response || 81 + "Connection successful!"; 82 + showStatus(`✅ ${content}`, "success"); 83 + } else { 84 + throw new Error(response.error || "Connection failed"); 85 + } 86 + } catch (error) { 87 + console.error("Test connection error:", error); 88 + 89 + let errorMsg = error.message; 90 + 91 + // Provide helpful troubleshooting 92 + if ( 93 + errorMsg.includes("Cannot connect") || 94 + errorMsg.includes("Failed to fetch") 95 + ) { 96 + errorMsg += "\n\n💡 Troubleshooting:\n"; 97 + errorMsg += "1. Check if Ollama is running: run `ollama serve`\n"; 98 + errorMsg += 99 + "2. Check if the model is pulled: run `ollama pull " + 100 + settings.model + 101 + "`\n"; 102 + errorMsg += "3. Try switching API Mode (Native vs OpenAI-Compatible)"; 103 + } 104 + 105 + showStatus("❌ " + errorMsg, "error"); 106 + } 107 + }); 108 + 109 + // Reset to defaults 110 + resetBtn.addEventListener("click", () => { 111 + apiModeInput.value = defaultSettings.apiMode; 112 + apiBaseUrlInput.value = defaultSettings.apiBaseUrl; 113 + modelInput.value = defaultSettings.model; 114 + apiKeyInput.value = defaultSettings.apiKey; 115 + showStatus("Settings reset to defaults. Click Save to apply.", "success"); 116 + }); 117 + 118 + // Load settings 119 + async function loadSettings() { 120 + try { 121 + const settings = await chrome.storage.sync.get(defaultSettings); 122 + 123 + apiModeInput.value = settings.apiMode; 124 + apiBaseUrlInput.value = settings.apiBaseUrl; 125 + modelInput.value = settings.model; 126 + apiKeyInput.value = settings.apiKey; 127 + } catch (error) { 128 + showStatus("Error loading settings: " + error.message, "error"); 129 + } 130 + } 131 + 132 + // Show status message 133 + function showStatus(message, type) { 134 + // Replace newlines with <br> for display 135 + statusDiv.innerHTML = message.replace(/\n/g, "<br>"); 136 + statusDiv.className = "status " + type; 137 + 138 + if (type !== "loading") { 139 + setTimeout(() => { 140 + statusDiv.className = "status"; 141 + }, 8000); 142 + } 143 + }
+69
popup/marked.min.js
··· 1 + /** 2 + * marked v15.0.12 - a markdown parser 3 + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) 4 + * https://github.com/markedjs/marked 5 + */ 6 + 7 + /** 8 + * DO NOT EDIT THIS FILE 9 + * The code in this file is generated from files in ./src/ 10 + */ 11 + (function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports}; 12 + "use strict";var H=Object.defineProperty;var be=Object.getOwnPropertyDescriptor;var Te=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^<a /i,endATag:/^<\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/<!--(?:-?>|[\s\S]*?(?:-->|$))/,Ce=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|<![A-Z][\\s\\S]*?(?:>\\n*|$)|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)|</?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Ie=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",le).getRegex(),X={blockquote:Ie,code:_e,def:Ae,fences:Le,heading:ze,hr:O,html:Ce,lheading:oe,list:Ee,newline:$e,paragraph:le,table:I,text:Pe},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Oe={...X,lheading:Me,table:re,paragraph:h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?</\\1> *(?:\\n{2,}|\\s*$)|<tag(?:"[^"]*"|'[^']*'|\\s[^'"/>\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:I,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Q).replace("hr",O).replace("heading",` *#{1,6} *[^ 13 + ]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,D=/[\p{P}\p{S}]/u,W=/[\s\p{P}\p{S}]/u,ce=/[^\s\p{P}\p{S}]/u,Ze=h(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,W).getRegex(),pe=/(?!~)[\p{P}\p{S}]/u,Ge=/(?!~)[\s\p{P}\p{S}]/u,He=/(?:[^\s\p{P}\p{S}]|~)/u,Ne=/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^</[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^<![a-zA-Z]+\\s[\\s\\S]*?>|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/},nt={...j,br:h(ae).replace("{2,}","*").getRegex(),text:h(j.text).replace("\\b_","\\b_| {2,}\\n").replace(/\{2,\}/g,"*").getRegex()},B={normal:X,gfm:Oe,pedantic:Be},P={normal:J,gfm:j,breaks:nt,pedantic:tt};var st={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length<e;)n.push("");for(;s<n.length;s++)n[s]=n[s].trim().replace(m.slashPipe,"|");return n}function A(l,e,t){let n=l.length;if(n===0)return"";let s=0;for(;s<n;){let i=l.charAt(n-s-1);if(i===e&&!t)s++;else if(i!==e&&t)s++;else break}return l.slice(0,n-s)}function de(l,e){if(l.indexOf(e[1])===-1)return-1;let t=0;for(let n=0;n<l.length;n++)if(l[n]==="\\")n++;else if(l[n]===e[0])t++;else if(l[n]===e[1]&&(t--,t<0))return n;return t>0?-2:-1}function me(l,e,t,n,s){let i=e.href,r=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:r,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function rt(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` 14 + `).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(` 15 + `)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,` 16 + `)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],` 17 + `)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],` 18 + `).split(` 19 + `),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c<n.length;c++)if(this.rules.other.blockquoteStart.test(n[c]))a.push(n[c]),o=!0;else if(!o)a.push(n[c]);else break;n=n.slice(c);let p=a.join(` 20 + `),u=p.replace(this.rules.other.blockquoteSetextReplace,` 21 + $1`).replace(this.rules.other.blockquoteSetextReplace2,"");s=s?`${s} 22 + ${p}`:p,i=i?`${i} 23 + ${u}`:u;let d=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTokens(u,r,!0),this.lexer.state.top=d,n.length===0)break;let g=r.at(-1);if(g?.type==="code")break;if(g?.type==="blockquote"){let T=g,f=T.raw+` 24 + `+n.join(` 25 + `),y=this.blockquote(f);r[r.length-1]=y,s=s.substring(0,s.length-T.raw.length)+y.raw,i=i.substring(0,i.length-T.text.length)+y.text;break}else if(g?.type==="list"){let T=g,f=T.raw+` 26 + `+n.join(` 27 + `),y=this.list(f);r[r.length-1]=y,s=s.substring(0,s.length-g.raw.length)+y.raw,i=i.substring(0,i.length-T.raw.length)+y.raw,n=f.substring(r.at(-1).raw.length).split(` 28 + `);continue}}return{type:"blockquote",raw:s,tokens:r,text:i}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim(),s=n.length>1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(` 29 + `,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(` 30 + `,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+` 31 + `,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(` 32 + `,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=` 33 + `+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=` 34 + `+g}!T&&!g.trim()&&(T=!0),p+=G+` 35 + `,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;c<i.items.length;c++)if(this.lexer.state.top=!1,i.items[c].tokens=this.lexer.blockTokens(i.items[c].text,[]),!i.loose){let p=i.items[c].tokens.filter(d=>d.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c<i.items.length;c++)i.items[c].loose=!0;return i}}html(e){let t=this.rules.block.html.exec(e);if(t)return{type:"html",block:!0,raw:t[0],pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:t[0]}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),s=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:t[0],href:s,title:i}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=Y(t[1]),s=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),i=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(` 36 + `):[],r={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(let o of s)this.rules.other.tableAlignRight.test(o)?r.align.push("right"):this.rules.other.tableAlignCenter.test(o)?r.align.push("center"):this.rules.other.tableAlignLeft.test(o)?r.align.push("left"):r.align.push(null);for(let o=0;o<n.length;o++)r.header.push({text:n[o],tokens:this.lexer.inline(n[o]),header:!0,align:r.align[o]});for(let o of i)r.rows.push(Y(o,r.header.length).map((a,c)=>({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` 37 + `?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` 38 + `),this.blockTokens(e,this.tokens);for(let t=0;t<this.inlineQueue.length;t++){let n=this.inlineQueue[t];this.inlineTokens(n.src,n.tokens)}return this.inlineQueue=[],this.tokens}blockTokens(e,t=[],n=!1){for(this.options.pedantic&&(e=e.replace(m.tabCharGlobal," ").replace(m.spaceLine,""));e;){let s;if(this.options.extensions?.block?.some(r=>(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=` 39 + `:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` 40 + `+s.raw,r.text+=` 41 + `+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` 42 + `+s.raw,r.text+=` 43 + `+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=` 44 + `+s.raw,r.text+=` 45 + `+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=` 46 + `+s.raw,r.text+=` 47 + `+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+` 48 + `;return s?'<pre><code class="language-'+R(s)+'">'+(n?i:R(i,!0))+`</code></pre> 49 + `:"<pre><code>"+(n?i:R(i,!0))+`</code></pre> 50 + `}blockquote({tokens:e}){return`<blockquote> 51 + ${this.parser.parse(e)}</blockquote> 52 + `}html({text:e}){return e}heading({tokens:e,depth:t}){return`<h${t}>${this.parser.parseInline(e)}</h${t}> 53 + `}hr(e){return`<hr> 54 + `}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o<e.items.length;o++){let a=e.items[o];s+=this.listitem(a)}let i=t?"ol":"ul",r=t&&n!==1?' start="'+n+'"':"";return"<"+i+r+`> 55 + `+s+"</"+i+`> 56 + `}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`<li>${t}</li> 57 + `}checkbox({checked:e}){return"<input "+(e?'checked="" ':"")+'disabled="" type="checkbox">'}paragraph({tokens:e}){return`<p>${this.parser.parseInline(e)}</p> 58 + `}table(e){let t="",n="";for(let i=0;i<e.header.length;i++)n+=this.tablecell(e.header[i]);t+=this.tablerow({text:n});let s="";for(let i=0;i<e.rows.length;i++){let r=e.rows[i];n="";for(let o=0;o<r.length;o++)n+=this.tablecell(r[o]);s+=this.tablerow({text:n})}return s&&(s=`<tbody>${s}</tbody>`),`<table> 59 + <thead> 60 + `+t+`</thead> 61 + `+s+`</table> 62 + `}tablerow({text:e}){return`<tr> 63 + ${e}</tr> 64 + `}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`</${n}> 65 + `}strong({tokens:e}){return`<strong>${this.parser.parseInline(e)}</strong>`}em({tokens:e}){return`<em>${this.parser.parseInline(e)}</em>`}codespan({text:e}){return`<code>${R(e,!0)}</code>`}br(e){return"<br>"}del({tokens:e}){return`<del>${this.parser.parseInline(e)}</del>`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='<a href="'+e+'"';return t&&(r+=' title="'+R(t)+'"'),r+=">"+s+"</a>",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=`<img src="${e}" alt="${n}"`;return t&&(r+=` title="${R(t)}"`),r+=">",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:R(e.text)}};var _=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||w,this.options.renderer=this.options.renderer||new $,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new _}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s<e.length;s++){let i=e[s];if(this.options.extensions?.renderers?.[i.type]){let o=i,a=this.options.extensions.renderers[o.type].call({parser:this},o);if(a!==!1||!["space","hr","heading","code","table","blockquote","list","html","paragraph","text"].includes(o.type)){n+=a||"";continue}}let r=i;switch(r.type){case"space":{n+=this.renderer.space(r);continue}case"hr":{n+=this.renderer.hr(r);continue}case"heading":{n+=this.renderer.heading(r);continue}case"code":{n+=this.renderer.code(r);continue}case"table":{n+=this.renderer.table(r);continue}case"blockquote":{n+=this.renderer.blockquote(r);continue}case"list":{n+=this.renderer.list(r);continue}case"html":{n+=this.renderer.html(r);continue}case"paragraph":{n+=this.renderer.paragraph(r);continue}case"text":{let o=r,a=this.renderer.text(o);for(;s+1<e.length&&e[s+1].type==="text";)o=e[++s],a+=` 66 + `+this.renderer.text(o);t?n+=this.renderer.paragraph({type:"paragraph",raw:a,text:a,tokens:[{type:"text",raw:a,text:a,escaped:!0}]}):n+=a;continue}default:{let o='Token with "'+r.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return n}parseInline(e,t=this.renderer){let n="";for(let s=0;s<e.length;s++){let i=e[s];if(this.options.extensions?.renderers?.[i.type]){let o=this.options.extensions.renderers[i.type].call({parser:this},i);if(o!==!1||!["escape","html","link","image","strong","em","codespan","br","del","text"].includes(i.type)){n+=o||"";continue}}let r=i;switch(r.type){case"escape":{n+=t.text(r);break}case"html":{n+=t.html(r);break}case"link":{n+=t.link(r);break}case"image":{n+=t.image(r);break}case"strong":{n+=t.strong(r);break}case"em":{n+=t.em(r);break}case"codespan":{n+=t.codespan(r);break}case"br":{n+=t.br(r);break}case"del":{n+=t.del(r);break}case"text":{n+=t.text(r);break}default:{let o='Token with "'+r.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return n}};var L=class{options;block;constructor(e){this.options=e||w}static passThroughHooks=new Set(["preprocess","postprocess","processAllTokens"]);preprocess(e){return e}postprocess(e){return e}processAllTokens(e){return e}provideLexer(){return this.block?x.lex:x.lexInline}provideParser(){return this.block?b.parse:b.parseInline}};var E=class{defaults=z();options=this.setOptions;parse=this.parseMarkdown(!0);parseInline=this.parseMarkdown(!1);Parser=b;Renderer=$;TextRenderer=_;Lexer=x;Tokenizer=S;Hooks=L;constructor(...e){this.use(...e)}walkTokens(e,t){let n=[];for(let s of e)switch(n=n.concat(t.call(this,s)),s.type){case"table":{let i=s;for(let r of i.header)n=n.concat(this.walkTokens(r.tokens,t));for(let r of i.rows)for(let o of r)n=n.concat(this.walkTokens(o.tokens,t));break}case"list":{let i=s;n=n.concat(this.walkTokens(i.items,t));break}default:{let i=s;this.defaults.extensions?.childTokens?.[i.type]?this.defaults.extensions.childTokens[i.type].forEach(r=>{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=` 67 + Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error occurred:</p><pre>"+R(n.message+"",!0)+"</pre>";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex; 68 + 69 + if(__exports != exports)module.exports = exports;return module.exports}));
+457
popup/popup.css
··· 1 + * { 2 + margin: 0; 3 + padding: 0; 4 + box-sizing: border-box; 5 + } 6 + 7 + /* ── CSS variables – Light (default) ── */ 8 + :root { 9 + --bg: #f5f0e8; 10 + --bg-subtle: #ede8de; 11 + --border: #e0d8cc; 12 + --border-hover: #c8c0b4; 13 + --text: #1a1a1a; 14 + --text-secondary:#2a2a2a; 15 + --text-muted: #aaa; 16 + --text-faint: #ccc; 17 + --text-em: #666; 18 + --icon-btn: #aaa; 19 + --icon-btn-hover:#555; 20 + --primary-bg: #1a1a1a; 21 + --primary-bg-hover:#333; 22 + --primary-bg-active:#000; 23 + --primary-text: #f5f0e8; 24 + --secondary-color:#aaa; 25 + --scrollbar: #d8d0c4; 26 + --spinner-track: #e0d8cc; 27 + --spinner-head: #888; 28 + --error-bg: #fdf0f0; 29 + --error-border: #f0d8d8; 30 + --error-text: #c05050; 31 + --toast-bg: #1a1a1a; 32 + --toast-text: #f5f0e8; 33 + --code-bg: #ede8de; 34 + --code-text: #555; 35 + --pre-text: #444; 36 + --table-th: #ede8de; 37 + --link: #555; 38 + --link-hover: #111; 39 + --heading: #111; 40 + --strong: #111; 41 + --blockquote: #888; 42 + } 43 + 44 + /* ── Dark theme ── */ 45 + [data-theme="dark"] { 46 + --bg: #1a1a1a; 47 + --bg-subtle: #252525; 48 + --border: #2e2e2e; 49 + --border-hover: #444; 50 + --text: #e8e3db; 51 + --text-secondary:#d0cbc3; 52 + --text-muted: #666; 53 + --text-faint: #444; 54 + --text-em: #999; 55 + --icon-btn: #555; 56 + --icon-btn-hover:#bbb; 57 + --primary-bg: #e8e3db; 58 + --primary-bg-hover:#ccc8c0; 59 + --primary-bg-active:#fff; 60 + --primary-text: #1a1a1a; 61 + --secondary-color:#555; 62 + --scrollbar: #333; 63 + --spinner-track: #2e2e2e; 64 + --spinner-head: #888; 65 + --error-bg: #2a1a1a; 66 + --error-border: #4a2a2a; 67 + --error-text: #e08080; 68 + --toast-bg: #e8e3db; 69 + --toast-text: #1a1a1a; 70 + --code-bg: #252525; 71 + --code-text: #aaa; 72 + --pre-text: #bbb; 73 + --table-th: #252525; 74 + --link: #aaa; 75 + --link-hover: #e8e3db; 76 + --heading: #e8e3db; 77 + --strong: #e8e3db; 78 + --blockquote: #666; 79 + } 80 + 81 + body { 82 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 83 + width: 400px; 84 + height: 560px; 85 + background: var(--bg); 86 + color: var(--text); 87 + overflow: hidden; 88 + transition: background 0.15s, color 0.15s; 89 + } 90 + 91 + .app { 92 + display: flex; 93 + flex-direction: column; 94 + height: 100%; 95 + } 96 + 97 + /* ── Header ── */ 98 + .header { 99 + display: flex; 100 + justify-content: space-between; 101 + align-items: center; 102 + padding: 13px 18px; 103 + border-bottom: 1px solid var(--border); 104 + flex-shrink: 0; 105 + } 106 + 107 + .header-left { 108 + display: flex; 109 + align-items: center; 110 + gap: 7px; 111 + } 112 + 113 + .header-right { 114 + display: flex; 115 + align-items: center; 116 + gap: 2px; 117 + } 118 + 119 + .logo-mark { 120 + font-size: 13px; 121 + color: var(--text-muted); 122 + } 123 + 124 + .logo-text { 125 + font-size: 13px; 126 + font-weight: 600; 127 + color: var(--text); 128 + letter-spacing: 0.01em; 129 + } 130 + 131 + .icon-btn { 132 + background: transparent; 133 + border: none; 134 + cursor: pointer; 135 + color: var(--icon-btn); 136 + width: 28px; 137 + height: 28px; 138 + display: flex; 139 + align-items: center; 140 + justify-content: center; 141 + border-radius: 6px; 142 + transition: color 0.1s; 143 + flex-shrink: 0; 144 + } 145 + 146 + .icon-btn:hover { 147 + color: var(--icon-btn-hover); 148 + } 149 + 150 + /* ── Content area ── */ 151 + .content-container { 152 + flex: 1; 153 + overflow-y: auto; 154 + padding: 18px; 155 + scrollbar-width: thin; 156 + scrollbar-color: var(--scrollbar) transparent; 157 + } 158 + 159 + .content-container::-webkit-scrollbar { width: 3px; } 160 + .content-container::-webkit-scrollbar-track { background: transparent; } 161 + .content-container::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; } 162 + 163 + /* ── Initial state ── */ 164 + .initial-state { 165 + display: flex; 166 + flex-direction: column; 167 + align-items: flex-start; 168 + justify-content: flex-end; 169 + height: 100%; 170 + padding-bottom: 4px; 171 + gap: 5px; 172 + } 173 + 174 + .initial-state.hidden { display: none; } 175 + 176 + .initial-icon { 177 + font-size: 18px; 178 + color: var(--text-faint); 179 + margin-bottom: 4px; 180 + } 181 + 182 + .initial-title { 183 + font-size: 15px; 184 + font-weight: 600; 185 + color: var(--text); 186 + } 187 + 188 + .initial-sub { 189 + font-size: 12px; 190 + color: var(--text-muted); 191 + line-height: 1.5; 192 + } 193 + 194 + /* ── Loading ── */ 195 + .loading-wrap { 196 + display: flex; 197 + flex-direction: column; 198 + align-items: center; 199 + justify-content: center; 200 + height: 100%; 201 + gap: 12px; 202 + } 203 + 204 + .spinner { 205 + width: 18px; 206 + height: 18px; 207 + border: 1.5px solid var(--spinner-track); 208 + border-top-color: var(--spinner-head); 209 + border-radius: 50%; 210 + animation: spin 0.75s linear infinite; 211 + } 212 + 213 + @keyframes spin { to { transform: rotate(360deg); } } 214 + 215 + .loading-label { 216 + font-size: 11px; 217 + color: var(--text-muted); 218 + letter-spacing: 0.05em; 219 + text-transform: uppercase; 220 + } 221 + 222 + /* ── Result ── */ 223 + .result { 224 + font-size: 14px; 225 + line-height: 1.7; 226 + color: var(--text-secondary); 227 + animation: fadeUp 0.2s ease; 228 + } 229 + 230 + .result.hidden { display: none; } 231 + 232 + @keyframes fadeUp { 233 + from { opacity: 0; transform: translateY(3px); } 234 + to { opacity: 1; transform: translateY(0); } 235 + } 236 + 237 + /* ── Markdown ── */ 238 + .result > * + * { 239 + margin-top: 10px; 240 + } 241 + 242 + .result > *:first-child { 243 + margin-top: 0; 244 + } 245 + 246 + .result h1, 247 + .result h2, 248 + .result h3, 249 + .result h4, 250 + .result h5, 251 + .result h6 { 252 + font-weight: 600; 253 + color: var(--heading); 254 + line-height: 1.3; 255 + } 256 + 257 + .result h1 { font-size: 16px; margin-top: 16px; } 258 + .result h2 { font-size: 14px; margin-top: 14px; } 259 + .result h3 { font-size: 13px; margin-top: 12px; } 260 + .result h4, .result h5, .result h6 { font-size: 13px; color: var(--text-muted); margin-top: 10px; } 261 + 262 + .result p { line-height: 1.65; } 263 + .result p + p { margin-top: 6px; } 264 + 265 + .result ul, .result ol { 266 + padding-left: 18px; 267 + } 268 + 269 + .result li { line-height: 1.6; } 270 + .result li + li { margin-top: 2px; } 271 + .result li::marker { color: var(--text-faint); } 272 + 273 + .result strong { font-weight: 600; color: var(--strong); } 274 + .result em { font-style: italic; color: var(--text-em); } 275 + 276 + .result code { 277 + font-family: 'SF Mono', 'Cascadia Code', monospace; 278 + font-size: 11.5px; 279 + background: var(--code-bg); 280 + padding: 1px 5px; 281 + border-radius: 3px; 282 + color: var(--code-text); 283 + } 284 + 285 + .result pre { 286 + background: var(--code-bg); 287 + padding: 10px 12px; 288 + border-radius: 5px; 289 + overflow-x: auto; 290 + } 291 + 292 + .result pre code { 293 + background: transparent; 294 + padding: 0; 295 + color: var(--pre-text); 296 + } 297 + 298 + .result blockquote { 299 + border-left: 2px solid var(--border); 300 + padding: 2px 12px; 301 + color: var(--blockquote); 302 + font-style: italic; 303 + } 304 + 305 + .result blockquote + blockquote { 306 + margin-top: 6px; 307 + } 308 + 309 + .result a { color: var(--link); text-decoration: underline; } 310 + .result a:hover { color: var(--link-hover); } 311 + 312 + .result hr { 313 + border: none; 314 + border-top: 1px solid var(--border); 315 + } 316 + 317 + .result table { 318 + border-collapse: collapse; 319 + width: 100%; 320 + font-size: 12.5px; 321 + } 322 + 323 + .result th, .result td { 324 + border: 1px solid var(--border); 325 + padding: 5px 9px; 326 + text-align: left; 327 + } 328 + 329 + .result th { 330 + background: var(--table-th); 331 + font-weight: 600; 332 + } 333 + 334 + /* Streaming plain text */ 335 + .streaming-content { 336 + white-space: pre-wrap; 337 + word-wrap: break-word; 338 + font-size: 14px; 339 + line-height: 1.7; 340 + color: var(--text-secondary); 341 + } 342 + 343 + .streaming-content.streaming::after { 344 + content: '▋'; 345 + color: var(--text-muted); 346 + animation: blink-cursor 0.6s step-end infinite; 347 + margin-left: 1px; 348 + } 349 + 350 + @keyframes blink-cursor { 351 + 0%, 100% { opacity: 1; } 352 + 50% { opacity: 0; } 353 + } 354 + 355 + /* ── Footer ── */ 356 + .footer { 357 + display: flex; 358 + gap: 8px; 359 + padding: 11px 18px; 360 + border-top: 1px solid var(--border); 361 + flex-shrink: 0; 362 + align-items: center; 363 + } 364 + 365 + .footer-buttons { 366 + display: flex; 367 + gap: 8px; 368 + flex: 1; 369 + } 370 + 371 + .footer-btn { 372 + display: flex; 373 + align-items: center; 374 + justify-content: center; 375 + padding: 8px 16px; 376 + border-radius: 6px; 377 + font-family: inherit; 378 + font-size: 12.5px; 379 + font-weight: 500; 380 + cursor: pointer; 381 + transition: all 0.1s ease; 382 + border: none; 383 + } 384 + 385 + .footer-btn.primary { 386 + flex: 1; 387 + background: var(--primary-bg); 388 + color: var(--primary-text); 389 + } 390 + 391 + .footer-btn.primary:hover { background: var(--primary-bg-hover); } 392 + .footer-btn.primary:active { background: var(--primary-bg-active); } 393 + 394 + .footer-btn.secondary { 395 + background: transparent; 396 + color: var(--secondary-color); 397 + border: 1px solid var(--border); 398 + } 399 + 400 + .footer-btn.secondary:hover { 401 + color: var(--icon-btn-hover); 402 + border-color: var(--border-hover); 403 + } 404 + 405 + .footer-btn:disabled { 406 + opacity: 0.35; 407 + cursor: not-allowed; 408 + } 409 + 410 + /* Copy / icon button in footer */ 411 + .footer-icon-btn { 412 + width: 34px; 413 + height: 34px; 414 + border-radius: 6px; 415 + border: 1px solid var(--border); 416 + flex-shrink: 0; 417 + } 418 + 419 + .footer-icon-btn:hover { 420 + border-color: var(--border-hover); 421 + } 422 + 423 + .hidden { 424 + display: none !important; 425 + } 426 + 427 + /* ── Error ── */ 428 + .error-message { 429 + background: var(--error-bg); 430 + border: 1px solid var(--error-border); 431 + color: var(--error-text); 432 + padding: 11px 14px; 433 + border-radius: 6px; 434 + font-size: 12px; 435 + line-height: 1.5; 436 + } 437 + 438 + /* ── Toast ── */ 439 + .toast { 440 + position: fixed; 441 + bottom: 68px; 442 + left: 50%; 443 + transform: translateX(-50%); 444 + background: var(--toast-bg); 445 + color: var(--toast-text); 446 + padding: 6px 14px; 447 + border-radius: 20px; 448 + font-size: 11px; 449 + z-index: 100; 450 + white-space: nowrap; 451 + animation: toast-in 0.15s ease; 452 + } 453 + 454 + @keyframes toast-in { 455 + from { opacity: 0; transform: translateX(-50%) translateY(4px); } 456 + to { opacity: 1; transform: translateX(-50%) translateY(0); } 457 + }
+85
popup/popup.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <link rel="preconnect" href="https://fonts.googleapis.com"> 6 + <link rel="stylesheet" href="popup.css"> 7 + </head> 8 + <body> 9 + <div class="app"> 10 + <div class="header"> 11 + <div class="header-left"> 12 + <div class="logo-mark">✦</div> 13 + <span class="logo-text">Summarize</span> 14 + </div> 15 + <div class="header-right"> 16 + <!-- Theme toggle: cycles light → dark → system --> 17 + <button id="theme-btn" class="icon-btn" title="Toggle theme"> 18 + <!-- Sun icon (light mode) --> 19 + <svg id="theme-icon-light" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 20 + <circle cx="12" cy="12" r="5"/> 21 + <line x1="12" y1="1" x2="12" y2="3"/> 22 + <line x1="12" y1="21" x2="12" y2="23"/> 23 + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> 24 + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> 25 + <line x1="1" y1="12" x2="3" y2="12"/> 26 + <line x1="21" y1="12" x2="23" y2="12"/> 27 + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> 28 + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> 29 + </svg> 30 + <!-- Moon icon (dark mode) --> 31 + <svg id="theme-icon-dark" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="hidden"> 32 + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> 33 + </svg> 34 + <!-- Monitor icon (system mode) --> 35 + <svg id="theme-icon-system" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="hidden"> 36 + <rect x="2" y="3" width="20" height="14" rx="2" ry="2"/> 37 + <line x1="8" y1="21" x2="16" y2="21"/> 38 + <line x1="12" y1="17" x2="12" y2="21"/> 39 + </svg> 40 + </button> 41 + <button id="settings-btn" class="icon-btn" title="Settings"> 42 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 43 + <circle cx="12" cy="12" r="3"/> 44 + <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/> 45 + </svg> 46 + </button> 47 + </div> 48 + </div> 49 + 50 + <div class="content-container"> 51 + <div id="initial-state" class="initial-state"> 52 + <div class="initial-icon">✦</div> 53 + <p class="initial-title">Ready to summarize</p> 54 + <p class="initial-sub">Get an AI-powered summary of the page you're reading.</p> 55 + </div> 56 + <div id="result" class="result hidden"></div> 57 + </div> 58 + 59 + <div class="footer"> 60 + <div class="footer-buttons"> 61 + <button id="summarize-btn" class="footer-btn primary"> 62 + <span id="summarize-label">Quick Summary</span> 63 + </button> 64 + <button id="detail-btn" class="footer-btn secondary hidden"> 65 + <span>More Detail</span> 66 + </button> 67 + </div> 68 + <!-- Copy icon button --> 69 + <button id="copy-btn" class="icon-btn footer-icon-btn hidden" title="Copy summary"> 70 + <svg id="copy-icon-default" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 71 + <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/> 72 + <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/> 73 + </svg> 74 + <svg id="copy-icon-done" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="hidden"> 75 + <polyline points="20 6 9 17 4 12"/> 76 + </svg> 77 + </button> 78 + 79 + </div> 80 + </div> 81 + 82 + <script src="marked.min.js"></script> 83 + <script src="popup.js"></script> 84 + </body> 85 + </html>
+482
popup/popup.js
··· 1 + // Popup script - Simple summarizer, no streaming, no history, resets on close 2 + let isLoading = false; 3 + let currentPageContent = ""; 4 + let isExtracting = true; 5 + let quickSummary = ""; 6 + let detailedSummary = ""; 7 + let currentSummaryMode = "none"; // "none", "quick", "detailed" 8 + let currentTabId = null; 9 + let currentTabUrl = ""; 10 + 11 + const resultContainer = document.getElementById("result"); 12 + const initialState = document.getElementById("initial-state"); 13 + const summarizeBtn = document.getElementById("summarize-btn"); 14 + const summarizeLabel = document.getElementById("summarize-label"); 15 + const detailBtn = document.getElementById("detail-btn"); 16 + const copyBtn = document.getElementById("copy-btn"); 17 + const settingsBtn = document.getElementById("settings-btn"); 18 + const themeBtn = document.getElementById("theme-btn"); 19 + 20 + // Cache key prefix for session storage 21 + const QUICK_SUMMARY_CACHE_PREFIX = "quick_summary_cache_"; 22 + const DETAILED_SUMMARY_CACHE_PREFIX = "detailed_summary_cache_"; 23 + const CONTENT_CACHE_PREFIX = "content_cache_"; 24 + 25 + // ── Theme logic ────────────────────────────────────────────── 26 + // Cycles: light → dark → system 27 + const THEMES = ["light", "dark", "system"]; 28 + 29 + function applyTheme(theme) { 30 + const root = document.documentElement; 31 + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 32 + 33 + if (theme === "system") { 34 + root.setAttribute("data-theme", prefersDark ? "dark" : "light"); 35 + } else { 36 + root.setAttribute("data-theme", theme); 37 + } 38 + 39 + // Update icon visibility 40 + document.getElementById("theme-icon-light").classList.toggle("hidden", theme !== "light"); 41 + document.getElementById("theme-icon-dark").classList.toggle("hidden", theme !== "dark"); 42 + document.getElementById("theme-icon-system").classList.toggle("hidden", theme !== "system"); 43 + 44 + // Update tooltip 45 + const labels = { light: "Light mode", dark: "Dark mode", system: "System theme" }; 46 + themeBtn.title = labels[theme]; 47 + } 48 + 49 + let currentTheme = "system"; 50 + 51 + themeBtn.addEventListener("click", async () => { 52 + const idx = THEMES.indexOf(currentTheme); 53 + currentTheme = THEMES[(idx + 1) % THEMES.length]; 54 + applyTheme(currentTheme); 55 + await chrome.storage.sync.set({ theme: currentTheme }); 56 + }); 57 + 58 + // Re-apply when system preference changes (for "system" mode) 59 + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { 60 + if (currentTheme === "system") applyTheme("system"); 61 + }); 62 + 63 + function setSummarizeLabel(text) { 64 + summarizeLabel.textContent = text; 65 + } 66 + 67 + document.addEventListener("DOMContentLoaded", async () => { 68 + // Load saved theme before rendering anything 69 + currentTheme = (await chrome.storage.sync.get("theme")).theme || "system"; 70 + applyTheme(currentTheme); 71 + 72 + // Get current tab info 73 + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 74 + currentTabId = tab.id; 75 + currentTabUrl = tab.url; 76 + 77 + // Check for cached summary for this tab 78 + const cached = await chrome.storage.session.get([ 79 + QUICK_SUMMARY_CACHE_PREFIX + currentTabId, 80 + DETAILED_SUMMARY_CACHE_PREFIX + currentTabId, 81 + CONTENT_CACHE_PREFIX + currentTabId 82 + ]); 83 + 84 + const cachedQuickSummary = cached[QUICK_SUMMARY_CACHE_PREFIX + currentTabId]; 85 + const cachedDetailedSummary = cached[DETAILED_SUMMARY_CACHE_PREFIX + currentTabId]; 86 + const cachedContent = cached[CONTENT_CACHE_PREFIX + currentTabId]; 87 + 88 + resetUI(); 89 + summarizeBtn.disabled = true; 90 + setSummarizeLabel("Loading..."); 91 + 92 + // If we have cached content for this tab, restore it 93 + if (cachedContent && cachedContent.url === currentTabUrl) { 94 + currentPageContent = cachedContent.content; 95 + isExtracting = false; 96 + summarizeBtn.disabled = false; 97 + 98 + // Restore summaries if they exist 99 + if (cachedQuickSummary) { 100 + quickSummary = cachedQuickSummary.summary; 101 + } 102 + if (cachedDetailedSummary) { 103 + detailedSummary = cachedDetailedSummary.summary; 104 + } 105 + 106 + // Build combined display if we have any summaries 107 + if (quickSummary || detailedSummary) { 108 + let combinedContent = ""; 109 + if (quickSummary) { 110 + combinedContent += quickSummary; 111 + } 112 + if (detailedSummary) { 113 + if (combinedContent) combinedContent += "\n\n---\n\n"; 114 + combinedContent += detailedSummary; 115 + } 116 + showSummary(combinedContent); 117 + setSummarizeLabel("Regenerate"); 118 + // Show detail button only if we have quick but not detailed 119 + if (quickSummary && !detailedSummary) { 120 + detailBtn.classList.remove("hidden"); 121 + } else { 122 + detailBtn.classList.add("hidden"); 123 + } 124 + } else { 125 + // No summaries yet 126 + setSummarizeLabel("Quick Summary"); 127 + detailBtn.classList.add("hidden"); 128 + } 129 + } else { 130 + // No cache, extract fresh content 131 + await extractPageContent(); 132 + isExtracting = false; 133 + summarizeBtn.disabled = false; 134 + setSummarizeLabel("Quick Summary"); 135 + detailBtn.classList.add("hidden"); 136 + 137 + // Check if we should auto-trigger summarize (from context menu) 138 + const session = await chrome.storage.session.get(["triggerSummarize"]); 139 + if (session.triggerSummarize) { 140 + // Clear the flag 141 + await chrome.storage.session.remove("triggerSummarize"); 142 + // Trigger quick summarize 143 + if (!isLoading && !isExtracting && currentPageContent) { 144 + await generateQuickSummary(); 145 + } 146 + } 147 + } 148 + }); 149 + 150 + function resetUI() { 151 + quickSummary = ""; 152 + detailedSummary = ""; 153 + currentSummaryMode = "none"; 154 + resultContainer.innerHTML = ""; 155 + resultContainer.classList.add("hidden"); 156 + initialState.classList.remove("hidden"); 157 + setSummarizeLabel("Quick Summary"); 158 + summarizeBtn.disabled = false; 159 + copyBtn.classList.add("hidden"); 160 + detailBtn.classList.add("hidden"); 161 + isLoading = false; 162 + } 163 + 164 + summarizeBtn.addEventListener("click", async () => { 165 + if (isLoading) return; 166 + if (isExtracting) { 167 + showToast("Still loading page content…"); 168 + return; 169 + } 170 + if (!currentPageContent) { 171 + showToast("Could not extract page content. Try refreshing."); 172 + return; 173 + } 174 + // If regenerating, clear the cache first 175 + if (quickSummary && currentTabId) { 176 + await chrome.storage.session.remove([ 177 + QUICK_SUMMARY_CACHE_PREFIX + currentTabId, 178 + DETAILED_SUMMARY_CACHE_PREFIX + currentTabId 179 + ]); 180 + quickSummary = ""; 181 + detailedSummary = ""; 182 + } 183 + await generateQuickSummary(); 184 + }); 185 + 186 + detailBtn.addEventListener("click", async () => { 187 + if (isLoading) return; 188 + if (!currentPageContent) { 189 + showToast("Could not extract page content. Try refreshing."); 190 + return; 191 + } 192 + await generateDetailedSummary(); 193 + }); 194 + 195 + settingsBtn.addEventListener("click", () => chrome.runtime.openOptionsPage()); 196 + 197 + copyBtn.addEventListener("click", async () => { 198 + let contentToCopy = ""; 199 + if (quickSummary) { 200 + contentToCopy += quickSummary; 201 + } 202 + if (detailedSummary) { 203 + if (contentToCopy) contentToCopy += "\n\n---\n\n"; 204 + contentToCopy += detailedSummary; 205 + } 206 + if (!contentToCopy) return; 207 + 208 + try { 209 + await navigator.clipboard.writeText(contentToCopy); 210 + document.getElementById("copy-icon-default").classList.add("hidden"); 211 + document.getElementById("copy-icon-done").classList.remove("hidden"); 212 + setTimeout(() => { 213 + document.getElementById("copy-icon-default").classList.remove("hidden"); 214 + document.getElementById("copy-icon-done").classList.add("hidden"); 215 + }, 2000); 216 + } catch (e) { 217 + showToast("Could not copy to clipboard."); 218 + } 219 + }); 220 + 221 + async function extractPageContent() { 222 + try { 223 + const [tab] = await chrome.tabs.query({ 224 + active: true, 225 + currentWindow: true, 226 + }); 227 + if ( 228 + !tab.url || 229 + tab.url.startsWith("chrome://") || 230 + tab.url.startsWith("chrome-extension://") || 231 + tab.url.startsWith("edge://") 232 + ) { 233 + currentPageContent = ""; 234 + return; 235 + } 236 + const response = await chrome.tabs.sendMessage(tab.id, { 237 + action: "extract", 238 + }); 239 + if (response && response.content) { 240 + currentPageContent = response.content; 241 + } 242 + } catch (error) { 243 + console.error("Error extracting content:", error); 244 + currentPageContent = ""; 245 + } 246 + } 247 + 248 + function showToast(message) { 249 + const toast = document.createElement("div"); 250 + toast.className = "toast"; 251 + toast.textContent = message; 252 + document.body.appendChild(toast); 253 + setTimeout(() => toast.remove(), 3000); 254 + } 255 + 256 + function showSummary(content) { 257 + initialState.classList.add("hidden"); 258 + resultContainer.classList.remove("hidden"); 259 + resultContainer.innerHTML = renderMarkdown(content); 260 + copyBtn.classList.remove("hidden"); 261 + } 262 + 263 + async function generateQuickSummary() { 264 + setLoading(true); 265 + currentSummaryMode = "quick"; 266 + initialState.classList.add("hidden"); 267 + resultContainer.classList.remove("hidden"); 268 + resultContainer.innerHTML = ` 269 + <div class="loading-wrap"> 270 + <div class="spinner"></div> 271 + <span class="loading-label">Thinking…</span> 272 + </div> 273 + `; 274 + 275 + try { 276 + const settings = await chrome.storage.sync.get({ 277 + apiMode: "ollama", 278 + apiBaseUrl: "http://localhost:11434", 279 + model: "gpt-oss:20b-cloud", 280 + apiKey: "", 281 + }); 282 + 283 + const pageContentForLLM = currentPageContent.substring(0, 8000); 284 + 285 + const quickSummaryPrompt = `Please provide a "Quick Summary" of this webpage. Focus on the main points and key takeaways. Use markdown formatting (headings, bullet points, etc.). 286 + 287 + The "Quick Summary" should be 3-5 **short** one-sentence bullet points. Each of these bullet points should have key points/takeaways **bolded** so people can quickly scan.`; 288 + 289 + const apiMessages = [ 290 + { role: "system", content: "You are a helpful assistant that summarizes webpages concisely." }, 291 + { 292 + role: "system", 293 + content: `The following is the content of the current webpage:\n\n${pageContentForLLM}`, 294 + }, 295 + { 296 + role: "user", 297 + content: quickSummaryPrompt, 298 + }, 299 + ]; 300 + 301 + await chrome.runtime.sendMessage({ action: "ping" }).catch(() => {}); 302 + 303 + const response = await chrome.runtime.sendMessage({ 304 + action: "chat", 305 + data: { 306 + apiBaseUrl: settings.apiBaseUrl, 307 + model: settings.model, 308 + apiKey: settings.apiKey, 309 + messages: apiMessages, 310 + apiMode: settings.apiMode, 311 + }, 312 + }); 313 + 314 + if (!response || !response.success) { 315 + throw new Error(response?.error || "No response from extension"); 316 + } 317 + 318 + const summary = 319 + response.data.choices?.[0]?.message?.content || 320 + response.data.response || 321 + response.data.message?.content || 322 + "No response received"; 323 + 324 + quickSummary = summary; 325 + detailedSummary = ""; // Reset detailed summary when regenerating quick summary 326 + showSummary(quickSummary); 327 + setSummarizeLabel("Regenerate"); 328 + detailBtn.classList.remove("hidden"); 329 + 330 + // Cache the quick summary for this tab 331 + if (currentTabId) { 332 + await chrome.storage.session.set({ 333 + [QUICK_SUMMARY_CACHE_PREFIX + currentTabId]: { 334 + summary: quickSummary, 335 + url: currentTabUrl, 336 + timestamp: Date.now() 337 + }, 338 + [CONTENT_CACHE_PREFIX + currentTabId]: { 339 + content: currentPageContent, 340 + url: currentTabUrl 341 + } 342 + }); 343 + // Clear any old detailed summary cache since we're regenerating 344 + await chrome.storage.session.remove([DETAILED_SUMMARY_CACHE_PREFIX + currentTabId]); 345 + } 346 + } catch (error) { 347 + console.error("API Error:", error); 348 + resultContainer.innerHTML = `<div class="error-message">${escapeHtml(error.message)}</div>`; 349 + detailBtn.classList.add("hidden"); 350 + } finally { 351 + setLoading(false); 352 + } 353 + } 354 + 355 + async function generateDetailedSummary() { 356 + setLoading(true); 357 + currentSummaryMode = "detailed"; 358 + 359 + // Show loading indicator appended to current content 360 + const currentContent = quickSummary; 361 + resultContainer.innerHTML = renderMarkdown(currentContent) + ` 362 + <div class="loading-wrap" style="margin-top: 20px;"> 363 + <div class="spinner"></div> 364 + <span class="loading-label">Generating detailed summary…</span> 365 + </div> 366 + `; 367 + 368 + try { 369 + const settings = await chrome.storage.sync.get({ 370 + apiMode: "ollama", 371 + apiBaseUrl: "http://localhost:11434", 372 + model: "gpt-oss:20b-cloud", 373 + apiKey: "", 374 + }); 375 + 376 + const pageContentForLLM = currentPageContent.substring(0, 8000); 377 + 378 + const detailedPrompt = `Expand on this summary of the webpage, with sections that go into a bit more detail. These sections can include direct quotes from the webpage.`; 379 + 380 + const apiMessages = [ 381 + { role: "system", content: "You are a helpful assistant that provides detailed webpage summaries." }, 382 + { 383 + role: "system", 384 + content: `The following is the content of the current webpage:\n\n${pageContentForLLM}`, 385 + }, 386 + { 387 + role: "assistant", 388 + content: `Quick Summary:\n${quickSummary}`, 389 + }, 390 + { 391 + role: "user", 392 + content: detailedPrompt, 393 + }, 394 + ]; 395 + 396 + await chrome.runtime.sendMessage({ action: "ping" }).catch(() => {}); 397 + 398 + const response = await chrome.runtime.sendMessage({ 399 + action: "chat", 400 + data: { 401 + apiBaseUrl: settings.apiBaseUrl, 402 + model: settings.model, 403 + apiKey: settings.apiKey, 404 + messages: apiMessages, 405 + apiMode: settings.apiMode, 406 + }, 407 + }); 408 + 409 + if (!response || !response.success) { 410 + throw new Error(response?.error || "No response from extension"); 411 + } 412 + 413 + const summary = 414 + response.data.choices?.[0]?.message?.content || 415 + response.data.response || 416 + response.data.message?.content || 417 + "No response received"; 418 + 419 + detailedSummary = summary; 420 + 421 + // Combine quick and detailed summaries 422 + const combinedContent = quickSummary + "\n\n---\n\n" + detailedSummary; 423 + showSummary(combinedContent); 424 + setSummarizeLabel("Regenerate"); 425 + detailBtn.classList.add("hidden"); 426 + 427 + // Cache the detailed summary for this tab 428 + if (currentTabId) { 429 + await chrome.storage.session.set({ 430 + [DETAILED_SUMMARY_CACHE_PREFIX + currentTabId]: { 431 + summary: detailedSummary, 432 + url: currentTabUrl, 433 + timestamp: Date.now() 434 + } 435 + }); 436 + } 437 + } catch (error) { 438 + console.error("API Error:", error); 439 + // Show error but keep the quick summary 440 + resultContainer.innerHTML = renderMarkdown(quickSummary) + ` 441 + <div class="error-message" style="margin-top: 20px;"> 442 + ${escapeHtml(error.message)} 443 + </div> 444 + `; 445 + } finally { 446 + setLoading(false); 447 + } 448 + } 449 + 450 + function renderMarkdown(text) { 451 + if (typeof marked === "undefined" || !marked.parse) { 452 + return escapeHtml(text).replace(/\n/g, "<br>"); 453 + } 454 + try { 455 + return marked.parse(text, { 456 + breaks: true, 457 + gfm: true, 458 + headerIds: false, 459 + mangle: false, 460 + }); 461 + } catch (e) { 462 + return escapeHtml(text).replace(/\n/g, "<br>"); 463 + } 464 + } 465 + 466 + function setLoading(loading) { 467 + isLoading = loading; 468 + summarizeBtn.disabled = loading; 469 + detailBtn.disabled = loading; 470 + if (loading) { 471 + setSummarizeLabel("Thinking…"); 472 + } else { 473 + // When done loading, show "Regenerate" if we have a summary, otherwise "Quick Summary" 474 + setSummarizeLabel(quickSummary ? "Regenerate" : "Quick Summary"); 475 + } 476 + } 477 + 478 + function escapeHtml(text) { 479 + const div = document.createElement("div"); 480 + div.textContent = text; 481 + return div.innerHTML; 482 + }
+20
rules.json
··· 1 + [ 2 + { 3 + "id": 1, 4 + "priority": 1, 5 + "action": { 6 + "type": "modifyHeaders", 7 + "requestHeaders": [ 8 + { 9 + "header": "Origin", 10 + "operation": "set", 11 + "value": "http://localhost" 12 + } 13 + ] 14 + }, 15 + "condition": { 16 + "urlFilter": "http://localhost:11434/*", 17 + "resourceTypes": ["xmlhttprequest"] 18 + } 19 + } 20 + ]
+237
scripts/background.js
··· 1 + // Background script - handles API communication 2 + 3 + // Cache key prefixes (must match popup.js) 4 + const QUICK_SUMMARY_CACHE_PREFIX = "quick_summary_cache_"; 5 + const DETAILED_SUMMARY_CACHE_PREFIX = "detailed_summary_cache_"; 6 + const CONTENT_CACHE_PREFIX = "content_cache_"; 7 + 8 + chrome.runtime.onInstalled.addListener(() => { 9 + // Set default settings only if they don't already exist 10 + chrome.storage.sync.get(["apiMode"]).then((result) => { 11 + if (!result.apiMode) { 12 + // Settings don't exist yet, set defaults 13 + chrome.storage.sync.set({ 14 + apiMode: "ollama", 15 + apiBaseUrl: "http://localhost:11434", 16 + model: "gpt-oss:20b-cloud", 17 + apiKey: "", 18 + }); 19 + } 20 + }); 21 + 22 + // Create context menu item 23 + chrome.contextMenus.create({ 24 + id: "summarize-page", 25 + title: "Summarize this page", 26 + contexts: ["page", "selection"], 27 + }); 28 + }); 29 + 30 + // Clear cache when a tab is closed 31 + chrome.tabs.onRemoved.addListener((tabId) => { 32 + clearTabCache(tabId); 33 + }); 34 + 35 + // Clear cache when a tab navigates to a new URL 36 + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 37 + if (changeInfo.url) { 38 + // URL changed, clear the cache for this tab 39 + clearTabCache(tabId); 40 + } 41 + }); 42 + 43 + async function clearTabCache(tabId) { 44 + try { 45 + await chrome.storage.session.remove([ 46 + QUICK_SUMMARY_CACHE_PREFIX + tabId, 47 + DETAILED_SUMMARY_CACHE_PREFIX + tabId, 48 + CONTENT_CACHE_PREFIX + tabId 49 + ]); 50 + } catch (e) { 51 + console.error("[WebAI] Error clearing cache:", e); 52 + } 53 + } 54 + 55 + // Handle context menu clicks 56 + chrome.contextMenus.onClicked.addListener((info, tab) => { 57 + if (info.menuItemId === "summarize-page") { 58 + // Store a flag to trigger summarize when popup opens 59 + chrome.storage.session.set({ triggerSummarize: true, targetTabId: tab.id }); 60 + 61 + // Open the popup 62 + chrome.action.openPopup(); 63 + } 64 + }); 65 + 66 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 67 + if (request.action === "ping") { 68 + sendResponse({ success: true, message: "pong" }); 69 + return true; 70 + } 71 + 72 + if (request.action === "chat") { 73 + handleChatRequest(request.data) 74 + .then((response) => { 75 + sendResponse({ success: true, data: response }); 76 + }) 77 + .catch((error) => { 78 + console.error("Background script error:", error); 79 + sendResponse({ 80 + success: false, 81 + error: error.message || "Unknown error occurred", 82 + }); 83 + }); 84 + return true; // Keep channel open for async 85 + } 86 + 87 + if (request.action === "testOllama") { 88 + testOllamaConnection() 89 + .then(() => sendResponse({ success: true })) 90 + .catch((err) => sendResponse({ success: false, error: err.message })); 91 + return true; 92 + } 93 + }); 94 + 95 + async function testOllamaConnection() { 96 + const response = await fetch("http://localhost:11434/api/tags"); 97 + if (!response.ok) { 98 + throw new Error(`HTTP ${response.status}`); 99 + } 100 + const data = await response.json(); 101 + return data; 102 + } 103 + 104 + async function handleChatRequest(data) { 105 + const { apiBaseUrl, model, apiKey, messages, apiMode } = data; 106 + 107 + let useNativeOllama = apiMode === "ollama"; 108 + 109 + if (useNativeOllama) { 110 + return await callOllamaNative(apiBaseUrl, model, messages); 111 + } else { 112 + return await callOpenAICompatible(apiBaseUrl, model, apiKey, messages); 113 + } 114 + } 115 + 116 + async function callOllamaNative(baseUrl, model, messages) { 117 + // Merge all system messages into one so none are dropped 118 + const systemMsgs = messages.filter((m) => m.role === "system"); 119 + const systemContent = systemMsgs.map((m) => m.content).join("\n\n"); 120 + const otherMessages = messages.filter((m) => m.role !== "system"); 121 + const lastUserMsg = otherMessages.filter((m) => m.role === "user").pop(); 122 + 123 + // Build conversation context 124 + let prompt; 125 + if (otherMessages.length > 1) { 126 + const context = otherMessages 127 + .slice(0, -1) 128 + .map((m) => `${m.role}: ${m.content}`) 129 + .join("\n"); 130 + prompt = `Context:\n${context}\n\nUser: ${lastUserMsg?.content || ""}`; 131 + } else { 132 + prompt = lastUserMsg?.content || ""; 133 + } 134 + 135 + const url = baseUrl.replace(/\/$/, "") + "/api/generate"; 136 + 137 + const response = await fetch(url, { 138 + method: "POST", 139 + headers: { 140 + "Content-Type": "application/json", 141 + }, 142 + body: JSON.stringify({ 143 + model: model, 144 + prompt: prompt, 145 + system: systemContent, 146 + stream: false, 147 + options: { 148 + temperature: 0.7, 149 + num_predict: 2048, 150 + }, 151 + }), 152 + }); 153 + 154 + if (!response.ok) { 155 + const text = await response.text(); 156 + let errorMsg = `HTTP ${response.status}`; 157 + if (response.status === 403) { 158 + errorMsg = 159 + "403 Forbidden. Ollama is rejecting the request origin. Fix: restart Ollama with OLLAMA_ORIGINS=* (e.g. OLLAMA_ORIGINS=* ollama serve)."; 160 + } else { 161 + try { 162 + const err = JSON.parse(text); 163 + errorMsg = err.error || err.message || errorMsg; 164 + } catch (e) { 165 + errorMsg = text || errorMsg; 166 + } 167 + } 168 + throw new Error(errorMsg); 169 + } 170 + 171 + const data = await response.json(); 172 + 173 + return { 174 + choices: [ 175 + { 176 + message: { 177 + role: "assistant", 178 + content: data.response, 179 + }, 180 + }, 181 + ], 182 + model: model, 183 + }; 184 + } 185 + 186 + async function callOpenAICompatible(baseUrl, model, apiKey, messages) { 187 + let url = baseUrl.replace(/\/$/, ""); 188 + 189 + if (!url.includes("/v1")) { 190 + url = url + "/v1"; 191 + } 192 + 193 + url = url + "/chat/completions"; 194 + 195 + const response = await fetch(url, { 196 + method: "POST", 197 + headers: { 198 + "Content-Type": "application/json", 199 + ...(apiKey && { Authorization: `Bearer ${apiKey}` }), 200 + }, 201 + body: JSON.stringify({ 202 + model: model, 203 + messages: messages, 204 + stream: false, 205 + max_tokens: 2048, 206 + }), 207 + }); 208 + 209 + if (!response.ok) { 210 + const text = await response.text(); 211 + let errorMsg = `HTTP ${response.status}`; 212 + 213 + if (response.status === 403) { 214 + if (url.includes("/v1")) { 215 + errorMsg = 216 + "403 Forbidden. This often means: invalid API key, API key lacks permissions, or the server rejected the request origin."; 217 + } else { 218 + errorMsg = 219 + "403 Forbidden. If using Ollama, ensure it's running with: ollama serve"; 220 + } 221 + } else if (response.status === 405) { 222 + errorMsg = 223 + "405 Method not allowed. Check if the API URL is correct for your API mode (Native vs OpenAI-compatible)."; 224 + } else { 225 + try { 226 + const err = JSON.parse(text); 227 + errorMsg = err.error?.message || err.message || errorMsg; 228 + } catch (e) { 229 + errorMsg = text || errorMsg; 230 + } 231 + } 232 + 233 + throw new Error(errorMsg); 234 + } 235 + 236 + return await response.json(); 237 + }
+316
scripts/content.js
··· 1 + // Content script - extracts text from webpage 2 + 3 + (function() { 4 + 'use strict'; 5 + 6 + // Tags to extract text from - be more inclusive 7 + const CONTENT_TAGS = [ 8 + 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 9 + 'article', 'section', 'main', 'div', 'span', 10 + 'li', 'td', 'th', 'blockquote', 11 + 'code', 'pre', 'figcaption', 'figure', 12 + 'strong', 'b', 'em', 'i', 'a' 13 + ]; 14 + 15 + // Tags to exclude 16 + const EXCLUDE_TAGS = [ 17 + 'script', 'style', 'noscript', 'iframe', 18 + 'nav', 'aside', 'form', 'button', 'input' 19 + ]; 20 + 21 + function extractText() { 22 + // Extract text with structure 23 + let extractedText = ''; 24 + 25 + // Get title 26 + const title = document.title || ''; 27 + if (title) { 28 + extractedText += `Title: ${title}\n\n`; 29 + } 30 + 31 + // Get meta description 32 + const metaDesc = document.querySelector('meta[name="description"]'); 33 + if (metaDesc) { 34 + extractedText += `Description: ${metaDesc.getAttribute('content')}\n\n`; 35 + } 36 + 37 + // Extract content from body, skipping noise elements 38 + extractedText += extractTextFromElement(document.body); 39 + 40 + // Clean up the text 41 + extractedText = cleanText(extractedText); 42 + 43 + // Fallback: if we got very little content, try brute force extraction 44 + if (extractedText.length < 1000) { 45 + const fallbackText = extractTextFallback(); 46 + if (fallbackText.length > extractedText.length) { 47 + extractedText = `Title: ${title}\n\n${fallbackText}`; 48 + } 49 + } 50 + 51 + return extractedText; 52 + } 53 + 54 + function extractTextFallback() { 55 + // Brute force: get all paragraphs and divs with text content 56 + const selectors = [ 57 + 'article p', 'article div', '.content p', '.content div', 58 + '.post-content p', '.entry-content p', '.article-body p', 59 + 'main p', 'main div', '[role="main"] p', 60 + '.story p', '.story-body p', '#story p' 61 + ]; 62 + 63 + let text = ''; 64 + const seen = new Set(); 65 + 66 + for (const selector of selectors) { 67 + try { 68 + const elements = document.querySelectorAll(selector); 69 + for (const el of elements) { 70 + const content = el.textContent.trim(); 71 + // Skip if too short or already seen 72 + if (content.length < 20 || seen.has(content.substring(0, 100))) continue; 73 + 74 + // Check if visible 75 + const style = window.getComputedStyle(el); 76 + if (style.display === 'none' || style.visibility === 'hidden') continue; 77 + 78 + seen.add(content.substring(0, 100)); 79 + text += content + '\n\n'; 80 + } 81 + } catch (e) { 82 + // Ignore invalid selectors 83 + } 84 + } 85 + 86 + // Last resort: get all paragraphs on the page 87 + if (text.length < 500) { 88 + const allParagraphs = document.querySelectorAll('p'); 89 + for (const p of allParagraphs) { 90 + const content = p.textContent.trim(); 91 + if (content.length > 30 && !seen.has(content.substring(0, 100))) { 92 + const style = window.getComputedStyle(p); 93 + if (style.display === 'none' || style.visibility === 'hidden') continue; 94 + 95 + seen.add(content.substring(0, 100)); 96 + text += content + '\n\n'; 97 + } 98 + } 99 + } 100 + 101 + return text.substring(0, 15000); 102 + } 103 + 104 + function shouldSkipElement(el) { 105 + const tag = el.tagName.toLowerCase(); 106 + if (EXCLUDE_TAGS.includes(tag)) { 107 + return true; 108 + } 109 + 110 + // Skip hidden elements using live computed style 111 + try { 112 + const style = window.getComputedStyle(el); 113 + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { 114 + return true; 115 + } 116 + } catch (e) { 117 + // Computed style might fail for some elements 118 + } 119 + 120 + // Skip common noise elements by role/class/id (but protect main content) 121 + if (isMainContent(el)) return false; 122 + 123 + const role = el.getAttribute('role'); 124 + if (role === 'navigation' || role === 'banner' || role === 'complementary') { 125 + return true; 126 + } 127 + 128 + // Only skip if element is clearly a nav/footer/header, not if it just contains the word 129 + // Safely get className and id (they can be objects for SVG elements) 130 + let className = ''; 131 + let id = ''; 132 + 133 + if (el.className) { 134 + if (typeof el.className === 'string') { 135 + className = el.className; 136 + } else if (el.className.baseVal) { 137 + className = el.className.baseVal; 138 + } 139 + } 140 + 141 + if (el.id) { 142 + if (typeof el.id === 'string') { 143 + id = el.id; 144 + } else if (el.id.baseVal) { 145 + id = el.id.baseVal; 146 + } 147 + } 148 + 149 + const classAndId = (className + ' ' + id).toLowerCase(); 150 + const strictNoisePatterns = [ 151 + /^nav$/, /-nav$/, /^nav-/, /^navigation$/, 152 + /^footer$/, /-footer$/, /^footer-/, 153 + /^header$/, /^site-header$/, /^page-header$/, 154 + /^sidebar$/, /^advertisement$/, /^ad-container$/ 155 + ]; 156 + if (strictNoisePatterns.some(p => p.test(classAndId.trim()))) { 157 + return true; 158 + } 159 + 160 + return false; 161 + } 162 + 163 + function isMainContent(element) { 164 + // Check if element is likely main content 165 + const role = element.getAttribute('role'); 166 + const tagName = element.tagName.toLowerCase(); 167 + 168 + // Safely get className and id (they can be objects for SVG elements) 169 + let className = ''; 170 + let id = ''; 171 + 172 + if (element.className) { 173 + if (typeof element.className === 'string') { 174 + className = element.className.toLowerCase(); 175 + } else if (element.className.baseVal) { 176 + // SVGAnimatedString case 177 + className = element.className.baseVal.toLowerCase(); 178 + } 179 + } 180 + 181 + if (element.id) { 182 + if (typeof element.id === 'string') { 183 + id = element.id.toLowerCase(); 184 + } else if (element.id.baseVal) { 185 + id = element.id.baseVal.toLowerCase(); 186 + } 187 + } 188 + 189 + // Common content container patterns 190 + const contentPatterns = [ 191 + 'content', 'main-content', 'article-content', 'post-content', 192 + 'entry-content', 'page-content', 'story-content', 'body-content', 193 + 'article', 'post', 'entry', 'story', 'main' 194 + ]; 195 + 196 + const isContentClass = contentPatterns.some(p => 197 + className.includes(p) || id.includes(p) 198 + ); 199 + 200 + return role === 'main' || 201 + role === 'article' || 202 + tagName === 'main' || 203 + tagName === 'article' || 204 + isContentClass; 205 + } 206 + 207 + function extractTextFromElement(element, depth = 0) { 208 + let text = ''; 209 + const indent = ' '.repeat(depth); 210 + const elementTag = element.tagName.toLowerCase(); 211 + 212 + // Get direct text content of this element (if any) 213 + const directText = getDirectTextContent(element).trim(); 214 + if (directText.length > 20 && depth > 0) { 215 + // This element has meaningful direct text 216 + text += directText + '\n\n'; 217 + } 218 + 219 + for (const child of element.children) { 220 + const childTag = child.tagName.toLowerCase(); 221 + 222 + // Skip unwanted elements 223 + if (shouldSkipElement(child)) continue; 224 + 225 + // Handle headings with emphasis 226 + if (/^h[1-6]$/.test(childTag)) { 227 + const headingText = getTextContent(child).trim(); 228 + if (headingText) { 229 + const prefix = '#'.repeat(parseInt(childTag[1])); 230 + text += `\n${prefix} ${headingText}\n\n`; 231 + } 232 + } 233 + // Handle paragraphs 234 + else if (childTag === 'p') { 235 + const pText = getTextContent(child).trim(); 236 + if (pText.length > 5) { 237 + text += `${pText}\n\n`; 238 + } 239 + } 240 + // Handle lists 241 + else if (childTag === 'li') { 242 + const liText = getTextContent(child).trim(); 243 + if (liText) { 244 + text += `${indent}- ${liText}\n`; 245 + } 246 + } 247 + // Handle code blocks 248 + else if (childTag === 'pre' || childTag === 'code') { 249 + const codeText = getTextContent(child).trim(); 250 + if (codeText) { 251 + text += `\n\`\`\`\n${codeText}\n\`\`\`\n\n`; 252 + } 253 + } 254 + // Recursively process ALL other elements that might contain text 255 + else { 256 + const childText = extractTextFromElement(child, depth + 1); 257 + if (childText.trim()) { 258 + text += childText; 259 + } 260 + } 261 + } 262 + 263 + return text; 264 + } 265 + 266 + function getDirectTextContent(element) { 267 + // Get only the direct text nodes of this element (not children) 268 + let text = ''; 269 + for (const node of element.childNodes) { 270 + if (node.nodeType === Node.TEXT_NODE) { 271 + text += node.textContent; 272 + } 273 + } 274 + return text.trim(); 275 + } 276 + 277 + function getTextContent(element) { 278 + // Get text content but preserve some structure 279 + let text = ''; 280 + 281 + for (const node of element.childNodes) { 282 + if (node.nodeType === Node.TEXT_NODE) { 283 + text += node.textContent; 284 + } else if (node.nodeType === Node.ELEMENT_NODE) { 285 + const tagName = node.tagName.toLowerCase(); 286 + 287 + // Add newlines for block elements 288 + if (['br', 'p', 'div', 'li'].includes(tagName)) { 289 + text += ' ' + getTextContent(node) + ' '; 290 + } else { 291 + text += getTextContent(node); 292 + } 293 + } 294 + } 295 + 296 + return text; 297 + } 298 + 299 + function cleanText(text) { 300 + return text 301 + .replace(/[^\S\n]+/g, ' ') // Collapse spaces/tabs but preserve newlines 302 + .replace(/\n{3,}/g, '\n\n') // Collapse 3+ newlines to 2 303 + .replace(/^\s+|\s+$/g, '') // Trim 304 + .substring(0, 15000); // Limit length 305 + } 306 + 307 + // Listen for messages from popup 308 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 309 + if (request.action === 'extract') { 310 + const content = extractText(); 311 + sendResponse({ content }); 312 + } 313 + return true; 314 + }); 315 + 316 + })();