<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ chrome extension - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ chrome extension - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 08 Jul 2026 22:38:56 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/chrome-extension/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Turn Performance Audits into AI Fix Prompts with a DevTools Extension ]]>
                </title>
                <description>
                    <![CDATA[ Performance tools are good at showing you what's slow. They can tell you that your Largest Contentful Paint is 4.2 seconds, your JavaScript bundle is too large, or an image below the fold is loading t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-turn-performance-audits-into-ai-fix-prompts-with-a-devtools-extension/</link>
                <guid isPermaLink="false">6a3966d66a52acabf1b616a4</guid>
                
                    <category>
                        <![CDATA[ devtools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ web performance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Olamilekan Lamidi ]]>
                </dc:creator>
                <pubDate>Mon, 22 Jun 2026 16:46:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/70dc4877-86de-43ec-bdf5-9a6dc34b7bf1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Performance tools are good at showing you what's slow. They can tell you that your Largest Contentful Paint is 4.2 seconds, your JavaScript bundle is too large, or an image below the fold is loading too early.</p>
<p>But they usually don't tell you the next info you need as a developer: <strong>What should I ask my coding agent to change?</strong></p>
<p>AI coding agents can help you fix performance issues, but they need clear context. If you type "make this site faster", you'll often get broad advice. If you give the agent the metric, the affected resource, the likely cause, and the files to inspect first, you have a much better chance of getting a useful patch.</p>
<p>In this tutorial, you'll learn how to turn a browser performance finding into a structured AI fix prompt. You'll also see how to add a "Copy AI fix prompt" button to a Chrome DevTools extension.</p>
<p>I'll use PerfLens, a Chrome DevTools extension I built, as the example. But the same pattern works with any tool that can collect performance data.</p>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>You'll build a small pipeline that looks like this:</p>
<pre><code class="language-text">Performance finding
  -&gt; Structured issue object
  -&gt; AI fix prompt
  -&gt; Clipboard
  -&gt; Coding agent
  -&gt; Code change
  -&gt; Re-run audit
</code></pre>
<p>By the end, you will have:</p>
<ul>
<li><p>A <code>Finding</code> type for storing audit results</p>
</li>
<li><p>A prompt builder function</p>
</li>
<li><p>A copy-to-clipboard function</p>
</li>
<li><p>A DevTools panel button that copies the generated prompt</p>
</li>
<li><p>A simple way to verify whether the fix worked</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should understand:</p>
<ul>
<li><p>Basic JavaScript or TypeScript</p>
</li>
<li><p>Basic browser extension concepts</p>
</li>
<li><p>How Chrome DevTools panels work at a high level</p>
</li>
<li><p>How to use an AI coding agent such as Cursor, Claude Code, GitHub Copilot, or a similar tool</p>
</li>
</ul>
<p>You don't need to build a full performance auditing engine for this tutorial. The focus is the handoff between a performance tool and a coding agent.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-performance-reports-are-hard-to-turn-into-code-changes">Why Performance Reports Are Hard to Turn into Code Changes</a></p>
</li>
<li><p><a href="#heading-what-an-ai-fix-prompt-should-include">What an AI Fix Prompt Should Include</a></p>
</li>
<li><p><a href="#heading-how-to-store-a-performance-finding-as-structured-data">How to Store a Performance Finding as Structured Data</a></p>
</li>
<li><p><a href="#heading-how-to-choose-the-most-important-finding">How to Choose the Most Important Finding</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-ai-fix-prompt">How to Build the AI Fix Prompt</a></p>
</li>
<li><p><a href="#heading-how-to-copy-the-prompt-to-the-clipboard">How to Copy the Prompt to the Clipboard</a></p>
</li>
<li><p><a href="#heading-how-to-add-the-button-to-a-devtools-panel">How to Add the Button to a DevTools Panel</a></p>
</li>
<li><p><a href="#heading-how-to-verify-the-fix">How to Verify the Fix</a></p>
</li>
<li><p><a href="#heading-how-this-fits-alongside-lighthouse">How This Fits Alongside Lighthouse</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-performance-reports-are-hard-to-turn-into-code-changes">Why Performance Reports Are Hard to Turn into Code Changes</h2>
<p>A performance score is a symptom. For example, a report might say:</p>
<pre><code class="language-text">Largest Contentful Paint: 4.2 seconds
</code></pre>
<p>That number matters, but it doesn't tell you where the fix lives.</p>
<p>The cause might be:</p>
<ul>
<li><p>A large hero image</p>
</li>
<li><p>A render-blocking script</p>
</li>
<li><p>Too much JavaScript on the initial route</p>
</li>
<li><p>A slow API request</p>
</li>
<li><p>Missing image dimensions that cause layout shift</p>
</li>
</ul>
<p>As a developer, you usually have to translate the report into a code-level task.</p>
<p>That translation step takes time. It's also the step where a coding agent can help most, if you give it enough context.</p>
<p>Instead of asking your agent to "make the site faster", you can give it a focused brief:</p>
<pre><code class="language-text">The homepage has a 258.1 KB image affecting load performance.
Inspect the hero section and image component first.
Resize or compress the image without changing the layout.
Then explain how to verify the improvement.
</code></pre>
<p>This is easier for the agent to act on because it points to one specific problem.</p>
<h2 id="heading-what-an-ai-fix-prompt-should-include">What an AI Fix Prompt Should Include</h2>
<p>A good AI fix prompt should read like a short engineering brief.</p>
<p>It should include:</p>
<ul>
<li><p>The performance problem</p>
</li>
<li><p>The measured evidence</p>
</li>
<li><p>The affected page or resource</p>
</li>
<li><p>The likely cause</p>
</li>
<li><p>The files or patterns to inspect first</p>
</li>
<li><p>A recommended fix</p>
</li>
<li><p>Constraints for the change</p>
</li>
<li><p>Verification steps</p>
</li>
</ul>
<p>Here is an example prompt:</p>
<pre><code class="language-text">You are helping optimize a Next.js app in a production build.

Problem: Image is 258.1 KB and may be slowing down the page.
Evidence: Image size = 258.1 KB
Page: http://localhost:3000
Affected resource: http://localhost:3000/_next/image?url=%2Fhome%2Four_story.webp&amp;w=3840&amp;q=75

Likely cause:
The page is loading an image that is larger than needed for its rendered size.

Inspect first:
- app/page.tsx or pages/index.tsx
- components/**/*.{tsx,jsx}
- next.config.js
- the hero section or image component

Recommended fix:
Resize or compress the image, use an appropriate modern format, and keep explicit width and height values so the layout does not shift.

Constraints:
- Keep the change local to the route or component causing the issue.
- Do not add a new dependency unless there is no reasonable alternative.
- Explain the change before applying it.

After the change:
- Re-run the performance audit.
- Confirm the image transfer size is lower.
- Confirm the layout still looks correct.
</code></pre>
<p>This prompt is specific. It tells the agent what happened, where to look, what to change, and how to check the result.</p>
<p>That's the core idea behind an AI patch brief.</p>
<p>Here is what that looks like inside PerfLens. A single performance finding is rendered as an AI patch brief, with the measured value, the affected resource, and the generated prompt gathered in one place. The "Copy AI fix prompt" button then hands the whole brief off to your coding agent in one click.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69daa79bc8e5007ddbe1b633/2f5fa3ec-f3c1-44d0-b53b-3ce37fac65e9.png" alt="PerfLens screenshot" style="display:block;margin:0 auto" width="2652" height="1024" loading="lazy">

<h2 id="heading-how-to-store-a-performance-finding-as-structured-data">How to Store a Performance Finding as Structured Data</h2>
<p>Before you can build a prompt, you need to store the performance issue as data.</p>
<p>Here is a simple TypeScript type:</p>
<pre><code class="language-typescript">type Finding = {
  id: string;
  title: string;
  metric: string;
  measured: string;
  budget?: string;
  resource?: string;
  likelyCause: string;
  recommendedFix: string;
  inspectFirst: string[];
  severity: "low" | "medium" | "high";
};
</code></pre>
<p>Each field has a job:</p>
<ul>
<li><p><code>id</code> identifies the type of issue.</p>
</li>
<li><p><code>title</code> gives the human-readable summary.</p>
</li>
<li><p><code>metric</code> names the measurement.</p>
</li>
<li><p><code>measured</code> stores the actual value.</p>
</li>
<li><p><code>budget</code> stores the target value, if you have one.</p>
</li>
<li><p><code>resource</code> stores the affected URL, file, or asset.</p>
</li>
<li><p><code>likelyCause</code> explains why the issue may be happening.</p>
</li>
<li><p><code>recommendedFix</code> gives the agent a direction.</p>
</li>
<li><p><code>inspectFirst</code> points the agent toward likely files.</p>
</li>
<li><p><code>severity</code> helps you decide what to show first.</p>
</li>
</ul>
<p>Here is an example finding for an oversized image:</p>
<pre><code class="language-typescript">const finding: Finding = {
  id: "image-weight",
  title: "Image is 258.1 KB and may be slowing down the page",
  metric: "Image size",
  measured: "258.1 KB",
  resource: "http://localhost:3000/_next/image?url=%2Fhome%2Four_story.webp&amp;w=3840&amp;q=75",
  likelyCause:
    "The page is loading an image that is larger than needed for its rendered size.",
  recommendedFix:
    "Resize or compress the image, use an appropriate modern format, and keep explicit width and height values.",
  inspectFirst: [
    "app/page.tsx or pages/index.tsx",
    "components/**/*.{tsx,jsx}",
    "next.config.js",
    "the hero section or image component",
  ],
  severity: "high",
};
</code></pre>
<p>At this stage, you aren't doing anything with AI yet. You're only turning a performance result into a clean object.</p>
<p>That object gives you something reliable to transform into a prompt later.</p>
<h2 id="heading-how-to-choose-the-most-important-finding">How to Choose the Most Important Finding</h2>
<p>You should avoid sending ten unrelated performance issues to an agent at once.</p>
<p>A large prompt with many issues can lead to a large patch. That makes the result harder to review.</p>
<p>A better approach is to generate one prompt per finding.</p>
<p>You can start with a simple severity score:</p>
<pre><code class="language-typescript">function scoreFinding(finding: Finding): number {
  const severityWeight = {
    low: 1,
    medium: 2,
    high: 3,
  };

  return severityWeight[finding.severity];
}
</code></pre>
<p>Then you can sort findings by score:</p>
<pre><code class="language-typescript">function sortFindings(findings: Finding[]): Finding[] {
  return [...findings].sort(
    (a, b) =&gt; scoreFinding(b) - scoreFinding(a)
  );
}
</code></pre>
<p>This is a simple version, but it's enough to get started.</p>
<p>Later, you can improve the score by considering:</p>
<ul>
<li><p>How far the metric is over budget</p>
</li>
<li><p>Whether the issue affects Largest Contentful Paint</p>
</li>
<li><p>Whether the issue affects layout shift or interaction delay</p>
</li>
<li><p>Whether the affected resource is part of the first page load</p>
</li>
<li><p>How confident you are in the recommended fix</p>
</li>
</ul>
<p>The goal isn't to create a perfect scoring system. The goal is to help you focus on one high-impact issue at a time.</p>
<h2 id="heading-how-to-build-the-ai-fix-prompt">How to Build the AI Fix Prompt</h2>
<p>Once you have a <code>Finding</code>, building the prompt becomes a string formatting task.</p>
<p>You also need a small amount of page context:</p>
<pre><code class="language-typescript">type PageContext = {
  framework: string;
  mode: string;
  pageUrl: string;
};
</code></pre>
<p>Page context is a few facts about the page the finding came from: the framework the app uses, whether it's a development or production build, and the URL being audited.</p>
<p>The finding tells the agent <em>what</em> is slow. The page context tells it <em>where</em> the fix will land and <em>how</em> the code is built. This matters because the same problem is fixed differently from one stack to the next. An oversized image is handled through <code>next/image</code> and <code>next.config.js</code> in Next.js, but through other files and conventions elsewhere. The <code>mode</code> field also hints whether production optimizations should already be in place.</p>
<p>Giving the agent this up front means it spends less effort guessing about your setup and more on the actual fix.</p>
<p>Then you can create a prompt builder:</p>
<pre><code class="language-typescript">function buildFixPrompt(finding: Finding, ctx: PageContext): string {
  const lines = [
    "You are helping optimize a " + ctx.framework + " app in a " + ctx.mode + " build.",
    "",
    "Problem: " + finding.title,
    "Evidence: " + finding.metric + " = " + finding.measured +
      (finding.budget ? " (budget: " + finding.budget + ")" : ""),
    "Page: " + ctx.pageUrl,
  ];

  if (finding.resource) {
    lines.push("Affected resource: " + finding.resource);
  }

  lines.push(
    "",
    "Likely cause:",
    finding.likelyCause,
    "",
    "Inspect first:",
    ...finding.inspectFirst.map((file) =&gt; "- " + file),
    "",
    "Recommended fix:",
    finding.recommendedFix,
    "",
    "Constraints:",
    "- Keep the change local to the route or component causing the measured cost.",
    "- Do not add new dependencies unless there is no reasonable alternative.",
    "- Explain the change before applying it.",
    "",
    "After the change:",
    "- Re-run the performance audit.",
    "- Confirm the measured issue improved.",
    "- Check that the UI still works correctly.",
  );

  return lines.join("\n");
}
</code></pre>
<p>You can call it like this:</p>
<pre><code class="language-typescript">const pageContext: PageContext = {
  framework: "Next.js",
  mode: "production",
  pageUrl: "http://localhost:3000",
};

const prompt = buildFixPrompt(finding, pageContext);
</code></pre>
<p>The output is a prompt you can paste into a coding agent.</p>
<p>The <code>framework</code> field is especially useful. If the agent knows the app uses Next.js, it can look for files such as <code>app/page.tsx</code>, <code>pages/index.tsx</code>, <code>next.config.js</code>, and image usage through <code>next/image</code>.</p>
<h2 id="heading-how-to-copy-the-prompt-to-the-clipboard">How to Copy the Prompt to the Clipboard</h2>
<p>The safest integration is clipboard-first.</p>
<p>Many coding agents and editors support different launch methods. Some support deep links. Some run in the terminal. Some live inside an editor. But every agent can accept pasted text.</p>
<p>Here's a small copy function:</p>
<pre><code class="language-typescript">async function copyPrompt(prompt: string): Promise&lt;void&gt; {
  await navigator.clipboard.writeText(prompt);
}
</code></pre>
<p>In a browser extension UI, call this from a user action such as a button click:</p>
<pre><code class="language-typescript">copyButton.addEventListener("click", async () =&gt; {
  const prompt = buildFixPrompt(finding, pageContext);

  await copyPrompt(prompt);

  copyButton.textContent = "Prompt copied";
});
</code></pre>
<p>You can also try to open an editor after copying the prompt:</p>
<pre><code class="language-typescript">type AgentTarget = "cursor" | "vscode" | "copy-only";

async function sendToAgent(
  prompt: string,
  target: AgentTarget
): Promise&lt;void&gt; {
  await navigator.clipboard.writeText(prompt);

  if (target === "cursor") {
    window.location.href = "cursor://";
    return;
  }

  if (target === "vscode") {
    window.location.href = "vscode://";
    return;
  }
}
</code></pre>
<p>This doesn't paste the prompt into the agent automatically. It only copies the prompt and tries to open the selected tool.</p>
<p>That is a useful limitation. It keeps the workflow predictable and lets you review the prompt before sending it.</p>
<h2 id="heading-how-to-add-the-button-to-a-devtools-panel">How to Add the Button to a DevTools Panel</h2>
<p>If you build this into a Chrome extension, you can expose it inside a DevTools panel.</p>
<p>First, register a DevTools page in your <code>manifest.json</code> file:</p>
<pre><code class="language-json">{
  "manifest_version": 3,
  "name": "PerfLens",
  "version": "1.0.0",
  "devtools_page": "devtools.html",
  "permissions": ["clipboardWrite", "activeTab", "scripting"]
}
</code></pre>
<p>Then create the panel from your DevTools script:</p>
<pre><code class="language-typescript">chrome.devtools.panels.create(
  "PerfLens",
  "icons/icon-32.png",
  "panel.html"
);
</code></pre>
<p>Inside the panel, render each finding with a button:</p>
<pre><code class="language-typescript">function renderFinding(
  finding: Finding,
  ctx: PageContext
): HTMLElement {
  const item = document.createElement("article");
  const title = document.createElement("h3");
  const button = document.createElement("button");

  title.textContent = finding.title;
  button.textContent = "Copy AI fix prompt";

  button.addEventListener("click", async () =&gt; {
    const prompt = buildFixPrompt(finding, ctx);

    await sendToAgent(prompt, "copy-only");

    button.textContent = "Prompt copied";
  });

  item.append(title, button);

  return item;
}
</code></pre>
<p>The important part is the button handler.</p>
<p>When you click the button, your extension:</p>
<ol>
<li><p>Builds a prompt from the performance finding.</p>
</li>
<li><p>Copies the prompt to the clipboard.</p>
</li>
<li><p>Shows feedback that the prompt was copied.</p>
</li>
</ol>
<p>You can then paste the prompt into your coding agent and review the suggested patch.</p>
<h2 id="heading-how-to-verify-the-fix">How to Verify the Fix</h2>
<p>An AI-generated patch is only useful if the metric improves.</p>
<p>After the agent suggests a change, you should:</p>
<ol>
<li><p>Review the code diff.</p>
</li>
<li><p>Run the app locally.</p>
</li>
<li><p>Reload the page.</p>
</li>
<li><p>Re-run the performance audit.</p>
</li>
<li><p>Compare the new measurement with the original one.</p>
</li>
</ol>
<p>For the image example, you would check:</p>
<ul>
<li><p>Did the image transfer size go down?</p>
</li>
<li><p>Does the image still look sharp enough?</p>
</li>
<li><p>Did the page layout stay stable?</p>
</li>
<li><p>Did Largest Contentful Paint improve?</p>
</li>
<li><p>Did the change affect any other route?</p>
</li>
</ul>
<p>This creates a simple loop:</p>
<pre><code class="language-text">Measure -&gt; Prompt -&gt; Patch -&gt; Measure again
</code></pre>
<p>You shouldn't treat the agent's answer as the final authority. The browser measurement is the final authority.</p>
<h2 id="heading-how-this-fits-alongside-lighthouse">How This Fits Alongside Lighthouse</h2>
<p>Lighthouse is still useful. It gives you a detailed lab audit and a consistent score. This workflow solves a different problem.</p>
<p>Lighthouse helps you answer:</p>
<pre><code class="language-text">How does this page perform under controlled conditions?
</code></pre>
<p>An AI patch brief helps you answer:</p>
<pre><code class="language-text">What should I ask my coding agent to fix right now?
</code></pre>
<p>You can use both.</p>
<p>Use Lighthouse for scoring, regression tracking, and deeper audits. Use an AI prompt workflow when you want to move from a specific finding to a code change faster.</p>
<h2 id="heading-a-note-on-privacy">A Note on Privacy</h2>
<p>AI fix prompts can include URLs, resource names, routes, filenames, and implementation details.</p>
<p>Before you paste a prompt into a cloud-based coding agent, check that it doesn't include:</p>
<ul>
<li><p>Access tokens</p>
</li>
<li><p>Private customer data</p>
</li>
<li><p>Internal URLs you can't share</p>
</li>
<li><p>Secrets from environment variables</p>
</li>
<li><p>Sensitive logs</p>
</li>
</ul>
<p>Keep the prompt focused on the performance issue. Give the agent enough context to help, but not more than it needs.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you learned how to turn a performance audit finding into an AI fix prompt.</p>
<p>You created:</p>
<ul>
<li><p>A structured <code>Finding</code> type</p>
</li>
<li><p>A way to rank findings</p>
</li>
<li><p>A <code>buildFixPrompt</code> function</p>
</li>
<li><p>A clipboard-first agent handoff</p>
</li>
<li><p>A DevTools panel button</p>
</li>
<li><p>A verification loop for checking the result</p>
</li>
</ul>
<p>The main idea is simple: performance tools produce evidence, and coding agents need context. A good AI patch brief connects the two.</p>
<p>PerfLens is one example of this workflow. If you want to try the extension or inspect how it implements this flow, you can find it here:</p>
<ul>
<li><p>Chrome Web Store: <a href="https://chromewebstore.google.com/detail/perflens/gkogamlpcnneeficmcdcnnnhobnbebdc">PerfLens</a></p>
</li>
<li><p>Source code: <a href="http://github.com/oluwatosinolamilekan/PerfLens">GitHub</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI-Powered, Local-First Chrome Extension That Turns Your Browsing History into an Intent Map ]]>
                </title>
                <description>
                    <![CDATA[ Your browser remembers every page you've ever opened, but it has no idea why you opened any of them. You might spend three days comparing laptops across a dozen tabs, get distracted, come back a week  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-powered-local-first-chrome-extension/</link>
                <guid isPermaLink="false">6a357903529dee82e5b4624b</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ context.dev ]]>
                    </category>
                
                    <category>
                        <![CDATA[ claude ]]>
                    </category>
                
                    <category>
                        <![CDATA[ indexeddb ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Fri, 19 Jun 2026 17:14:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/26289969-a243-46ff-87aa-095d4168bf17.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your browser remembers every page you've ever opened, but it has no idea why you opened any of them.</p>
<p>You might spend three days comparing laptops across a dozen tabs, get distracted, come back a week later, and your history just shows a flat list of timestamps and titles, with no sense that those visits were one thing, a decision you started and never finished.</p>
<p>In this tutorial, you'll build <strong>openloops</strong>, an open-source, local-first Chrome extension that fixes this by scanning your browsing history and grouping it into "intent threads" – the decisions, research, and open questions you keep coming back to – then scoring each one for how alive it still is. Optionally, it also uses Claude to label those threads in plain language, suggest a concrete next step, and power a chat assistant you can ask "what should I close this week?"</p>
<p>By the end, you'll have built:</p>
<ul>
<li><p>A Manifest V3 Chrome extension with a service worker and a full-tab dashboard</p>
</li>
<li><p>A local pipeline that captures, cleans, segments, and clusters browsing history entirely in IndexedDB</p>
</li>
<li><p>A clustering algorithm tuned and debugged on real (messy) browsing data</p>
</li>
<li><p>An AI labeling layer using Claude, with a grounding step that uses brand data from context.dev</p>
</li>
<li><p>A chat assistant that reasons across your threads and tells you what to do next</p>
</li>
<li><p>A polished dashboard with onboarding, a design system, and a working pipeline status machine</p>
</li>
</ul>
<p>Everything runs on-device, and the only network calls are optional and opt-in, made with your own API keys.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-openloops-is-structured">How openloops Is Structured</a></p>
<ul>
<li><p><a href="#heading-the-shared-types">The shared types</a></p>
</li>
<li><p><a href="#heading-the-manifest">The manifest</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-scaffold-the-extension">How to Scaffold the Extension</a></p>
</li>
<li><p><a href="#heading-how-to-capture-your-browsing-history">How to Capture Your Browsing History</a></p>
<ul>
<li><p><a href="#heading-a-few-shared-helpers">A few shared helpers</a></p>
</li>
<li><p><a href="#heading-the-database-layer-so-far">The database layer (so far)</a></p>
</li>
<li><p><a href="#heading-capturing-new-visits-live">Capturing new visits live</a></p>
</li>
<li><p><a href="#heading-backfilling-14-days-of-history">Backfilling 14 days of history</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-turn-noise-into-sessions">How to Turn Noise into Sessions</a></p>
<ul>
<li><p><a href="#heading-filtering-out-noise">Filtering out noise</a></p>
</li>
<li><p><a href="#heading-extracting-keywords">Extracting keywords</a></p>
</li>
<li><p><a href="#heading-extending-the-database-for-sessions">Extending the database for sessions</a></p>
</li>
<li><p><a href="#heading-segmenting-events-into-sessions">Segmenting events into sessions</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-cluster-sessions-into-intent-threads">How to Cluster Sessions into Intent Threads</a></p>
<ul>
<li><p><a href="#heading-detecting-ambient-domains">Detecting ambient domains</a></p>
</li>
<li><p><a href="#heading-extending-the-database-for-intent-threads">Extending the database for intent threads</a></p>
</li>
<li><p><a href="#heading-clustering-sessions-into-threads">Clustering sessions into threads</a></p>
</li>
<li><p><a href="#heading-scoring-and-classifying-threads">Scoring and classifying threads</a></p>
</li>
<li><p><a href="#heading-putting-it-together">Putting it together</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-clean-up-self-referential-noise">How to Clean Up Self-Referential Noise</a></p>
<ul>
<li><p><a href="#heading-the-two-problems">The two problems</a></p>
</li>
<li><p><a href="#heading-one-definition-applied-everywhere">One definition, applied everywhere</a></p>
</li>
<li><p><a href="#heading-defending-the-enrichment-boundary-too">Defending the enrichment boundary too</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-label-threads-with-claude">How to Label Threads with Claude</a></p>
<ul>
<li><p><a href="#heading-storing-keys-locally">Storing keys locally</a></p>
</li>
<li><p><a href="#heading-the-first-version-and-how-it-broke">The first version, and how it broke</a></p>
</li>
<li><p><a href="#heading-batching-the-requests">Batching the requests</a></p>
</li>
<li><p><a href="#heading-building-the-prompt-and-merging-results">Building the prompt and merging results</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-ground-labels-with-contextdev">How to Ground Labels with context.dev</a></p>
<ul>
<li><p><a href="#heading-what-the-api-returns">What the API returns</a></p>
</li>
<li><p><a href="#heading-fetching-one-brand">Fetching one brand</a></p>
</li>
<li><p><a href="#heading-enriching-domains-in-batches">Enriching domains in batches</a></p>
</li>
<li><p><a href="#heading-how-grounding-feeds-back-into-labeling">How grounding feeds back into labeling</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-design-the-dashboard">How to Design the Dashboard</a></p>
<ul>
<li><p><a href="#heading-the-three-column-layout">The three-column layout</a></p>
</li>
<li><p><a href="#heading-the-pipeline-state-machine">The pipeline state machine</a></p>
</li>
<li><p><a href="#heading-driving-the-welcome-screen-from-the-same-machine">Driving the welcome screen from the same machine</a></p>
</li>
<li><p><a href="#heading-wiring-the-handlers">Wiring the handlers</a></p>
</li>
<li><p><a href="#heading-the-resume-button">The Resume button</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-build-the-ai-assistant">How to Build the AI Assistant</a></p>
<ul>
<li><p><a href="#heading-grounding-the-conversation">Grounding the conversation</a></p>
</li>
<li><p><a href="#heading-sending-a-message">Sending a message</a></p>
</li>
<li><p><a href="#heading-model-and-effort-controls">Model and effort controls</a></p>
</li>
<li><p><a href="#heading-rendering-replies-and-the-empty-state">Rendering replies and the empty state</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-youve-built-and-where-to-take-it">What You've Built, and Where to Take It</a></p>
<ul>
<li><p><a href="#heading-what-the-privacy-model-adds-up-to">What the privacy model adds up to</a></p>
</li>
<li><p><a href="#heading-where-to-take-it-next">Where to take it next</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping up</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
<ul>
<li><p><a href="#heading-source-code">Source code</a></p>
</li>
<li><p><a href="#heading-core-documentation">Core documentation</a></p>
</li>
<li><p><a href="#heading-services-used">Services used</a></p>
</li>
<li><p><a href="#heading-build-tooling">Build tooling</a></p>
</li>
<li><p><a href="#heading-debugging-tools">Debugging tools</a></p>
</li>
<li><p><a href="#heading-further-reading">Further reading</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>On first run, openloops greets you with a centered welcome screen that walks you through the three pipeline steps:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/70b376c4-e08d-45c3-9526-cad948d7bc08.png" alt="openloops welcome screen, showing the three onboarding steps: scan your history, build sessions, and build your intent map" style="display:block;margin:0 auto" width="3456" height="2162" loading="lazy">

<p>Once you've scanned your history, built sessions, and built the intent map, your browsing reorganizes into status-grouped threads: active, stalled, and dormant. Each one has a confidence score, a plain-language summary, a concrete next step, and a <strong>Resume</strong> button that reopens the exact pages you left off on. The right column holds a chat assistant grounded in your own threads:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/15e4d096-76a0-44f6-9a90-d0bb4de20bb8.png" alt="openloops dashboard showing status-grouped intent threads on the left and an AI assistant chat reasoning about what to close this week on the right" style="display:block;margin:0 auto" width="3456" height="2164" loading="lazy">

<p>That assistant response reasons across the user's actual threads, ranking them by how easy they are to close against how much of a real decision they still need. It also explains why, which is the most novel part of this build, and depends on the context.dev grounding step you'll add later in this tutorial.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you'll need:</p>
<ul>
<li><p><strong>Node 18+</strong> and a Chromium-based browser (Chrome, Brave, Edge, and so on).</p>
</li>
<li><p>Comfort with <strong>TypeScript</strong> and <strong>React</strong>. You don't need to be an expert, but you should be comfortable reading hooks and async/await.</p>
</li>
<li><p>Basic familiarity with <strong>IndexedDB</strong> is helpful but not required, as you'll learn what you need as you go.</p>
</li>
</ul>
<p>Two parts of this build are optional and require your own API key, each with a free tier:</p>
<ul>
<li><p>An <strong>Anthropic API key</strong> (from <a href="https://platform.claude.com/settings/keys">platform.claude.com</a>) for AI labeling and the chat assistant</p>
</li>
<li><p>A <strong>context.dev API key</strong> (from <a href="https://www.context.dev/login">context.dev</a>) for the brand-grounding step</p>
</li>
</ul>
<p>You can build and use the entire core pipeline, capture, clustering, scoring, without either key, since both are additive layers on top of it.</p>
<h2 id="heading-how-openloops-is-structured">How openloops Is Structured</h2>
<p>Before writing any code, it helps to see the whole shape of the thing. Every stage of openloops reads from one IndexedDB store and writes to the next:</p>
<pre><code class="language-plaintext">chrome.history (backfill) ──┐
chrome.tabs.onUpdated (live)─┴─→ raw_events
                                     │  noise filter
                                     ▼
                                  sessions
                                     │  ambient detection + clustering + scoring
                                     ▼
                               intent_threads
                                     │
                                     ▼
                              React dashboard
                                     │  optional, opt-in
                                     ├──→ brand enrichment   (context.dev)
                                     └──→ AI labeling + next step (Claude)
                                              │
                                              ▼  optional, opt-in
                                        AI assistant chat (Claude)
</code></pre>
<p>Each stage is a separate module under <code>src/pipeline/</code>, and each one is independently inspectable: you can open Chrome DevTools, look at <code>raw_events</code>, <code>sessions</code>, or <code>intent_threads</code> directly in the Application tab, and rebuild any single stage without touching the others.</p>
<h3 id="heading-the-shared-types">The Shared Types</h3>
<p>Every stage consumes and produces the same handful of TypeScript interfaces, defined once in <code>src/types.ts</code>:</p>
<pre><code class="language-typescript">// Shared TypeScript interfaces for the openloops pipeline.
// Each stage of the pipeline consumes and produces these types.

export interface RawEvent {
  id: string;
  url: string;
  domain: string;
  title: string;
  visitedAt: number;         // epoch ms
  source: "backfill" | "live";
}

export interface Session {
  id: string;
  events: RawEvent[];
  startedAt: number;
  endedAt: number;
  domains: string[];
  keywords: string[];
}

export interface IntentThread {
  id: string;
  title: string;
  summary?: string;
  nextStep?: string;   // one concrete action to move the thread forward
  sessions: Session[];
  type: "buying" | "research" | "planning" | "learning" | "unclassified";
  confidence: number;        // 0-1
  status: "active" | "stalled" | "dormant";
  firstSeen: number;
  lastSeen: number;
  distinctDays: number;
  signals: string[];
}

export interface Brand {
  domain: string;
  name: string;
  description: string;
  industry: string;
  logoUrl: string;
  brandColor: string;
}
</code></pre>
<p>Most fields on <code>IntentThread</code>, <code>confidence</code>, <code>status</code>, <code>signals</code>, and <code>distinctDays</code> get filled in by pure local heuristics later in this guide, when you cluster and score threads. <code>summary</code> and <code>nextStep</code> stay <code>undefined</code> until the optional AI labeling step, covered after that, fills them in.</p>
<p>This is the pattern that makes the whole project work: the core data model functions on its own, and AI makes it richer.</p>
<h3 id="heading-the-manifest">The Manifest</h3>
<p>openloops is a Manifest V3 extension with three permissions and three host permissions:</p>
<pre><code class="language-json">{
  "manifest_version": 3,
  "name": "openloops",
  "version": "0.0.1",
  "description": "Reconstruct your browsing history into an AI-labeled map of intent threads: active decisions, stalled research, open questions. Fully local.",

  "permissions": ["history", "tabs", "storage"],
  "host_permissions": [
    "https://api.anthropic.com/*",
    "https://api.context.dev/*",
    "https://logos.context.dev/*"
  ],

  "background": {
    "service_worker": "src/background.ts",
    "type": "module"
  },

  "options_page": "src/dashboard/index.html",

  "icons": {
    "16": "icons/icon16.png",
    "32": "icons/icon32.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },

  "action": {
    "default_title": "openloops",
    "default_icon": {
      "16": "icons/icon16.png",
      "32": "icons/icon32.png"
    }
  }
}
</code></pre>
<p>The permissions, host permissions, and <code>options_page</code> entry each carry specific weight:</p>
<ul>
<li><p><code>permissions: ["history", "tabs", "storage"]</code> are the only permissions the <em>core pipeline</em> needs. <code>history</code> reads your browsing history for the backfill, <code>tabs</code> lets the service worker observe new page loads and lets "Resume" reopen tabs, and <code>storage</code> is where API keys and preferences live.</p>
</li>
<li><p><code>host_permissions</code> are separate, and only matter if you use the optional AI features. They're what let the dashboard make <code>fetch()</code> calls to Anthropic and context.dev without hitting CORS errors.</p>
</li>
<li><p><code>options_page</code> points at the dashboard. Setting it this way, instead of a <code>default_popup</code>, means clicking the toolbar icon opens the dashboard as a full browser tab rather than a tiny popup, which matters once you're looking at a multi-column layout with status-grouped cards and a chat panel.</p>
</li>
</ul>
<h2 id="heading-how-to-scaffold-the-extension">How to Scaffold the Extension</h2>
<p>Start with Vite and the <a href="https://crxjs.dev/vite-plugin">CRXJS plugin</a>, which compiles a Manifest V3 extension with hot module reloading:</p>
<pre><code class="language-bash">npm create vite@latest openloops -- --template react-ts
cd openloops
npm install @crxjs/vite-plugin idb react-markdown
</code></pre>
<p>Your <code>vite.config.ts</code> wires CRXJS to your <code>manifest.json</code>, and from there, Vite handles compiling <code>src/background.ts</code> to a real <code>.js</code> file that Chrome can load (a raw <code>.ts</code> service worker path in the manifest will fail with a registration error, which we'll debug in the next section).</p>
<p>The dashboard's entry point is a standard React 18 root:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;openloops&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id="root"&gt;&lt;/div&gt;
    &lt;script type="module" src="./main.tsx"&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<pre><code class="language-typescriptreact">import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./app.css";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  &lt;StrictMode&gt;
    &lt;App /&gt;
  &lt;/StrictMode&gt;
);
</code></pre>
<p>Build it, then load it as an unpacked extension:</p>
<pre><code class="language-bash">npm run build
</code></pre>
<p>In Chrome, go to <code>chrome://extensions</code>, enable <strong>Developer mode</strong>, click <strong>Load unpacked</strong>, and select the <code>dist/</code> folder. With nothing else built yet, clicking the toolbar icon should open a blank dashboard tab, and the service worker (visible from the extension card's "service worker" link) should log <code>[openloops] Extension installed.</code> on install.</p>
<p>With that foundation in place, it's time to start filling <code>raw_events</code> with your actual browsing history.</p>
<h2 id="heading-how-to-capture-your-browsing-history">How to Capture Your Browsing History</h2>
<p>Every record in openloops starts life as a <code>RawEvent</code>, the type you saw earlier: a URL, a domain, a title, a timestamp, and a <code>source</code> of either <code>"backfill"</code> or <code>"live"</code>.</p>
<p>Two pipelines populate it:</p>
<ul>
<li><p>A <strong>one-time backfill</strong> that reads your last 14 days of <code>chrome.history</code> on demand</p>
</li>
<li><p><strong>Live capture</strong>, which listens for new page loads from this point forward</p>
</li>
</ul>
<p>Both paths share a handful of small helpers and write through the same IndexedDB layer, so it's worth building those first.</p>
<h3 id="heading-a-few-shared-helpers">A Few Shared Helpers</h3>
<p>Create <code>src/lib/util.ts</code>:</p>
<pre><code class="language-typescript">export function isHttpUrl(url: string): boolean {
  return url.startsWith("http://") || url.startsWith("https://");
}

export function extractDomain(url: string): string {
  try {
    const { hostname } = new URL(url);
    return hostname.replace(/^www\./, "");
  } catch {
    return url;
  }
}

export function isLocalHost(domain: string): boolean {
  if (domain === "localhost" || domain === "127.0.0.1") return true;
  if (domain.endsWith(".local")) return true;

  const octets = domain.split(".");
  if (octets.length === 4 &amp;&amp; octets.every((o) =&gt; /^\d{1,3}$/.test(o))) {
    const [a, b] = octets.map(Number);
    if (a === 10) return true;
    if (a === 172 &amp;&amp; b &gt;= 16 &amp;&amp; b &lt;= 31) return true;
    if (a === 192 &amp;&amp; b === 168) return true;
  }

  return false;
}

export function hashId(url: string, visitedAt: number): string {
  const str = `\({url}|\){visitedAt}`;
  let hash = 5381;
  for (let i = 0; i &lt; str.length; i++) {
    hash = ((hash &lt;&lt; 5) + hash) ^ str.charCodeAt(i);
    hash |= 0;
  }
  return (hash &gt;&gt;&gt; 0).toString(36);
}
</code></pre>
<p>Each of these four functions solves a problem you won't notice until later in the build:</p>
<ul>
<li><p><code>isHttpUrl</code> is the shared scheme guard used by both live capture and the backfill, and the single gate that keeps <code>chrome://</code>, <code>chrome-extension://</code>, <code>about:</code>, and <code>file://</code> URLs out of your data entirely. Both capture paths call it before anything else.</p>
</li>
<li><p><code>extractDomain</code> strips a leading <code>www.</code> and returns the hostname, which is a simplification: <a href="http://bbc.co.uk"><code>bbc.co.uk</code></a> and <a href="http://news.bbc.co.uk"><code>news.bbc.co.uk</code></a> wouldn't collapse to the same domain under this logic, since true registrable-domain extraction needs the <a href="https://publicsuffix.org/">Public Suffix List</a>. If the URL is malformed, it just returns the input unchanged rather than throwing.</p>
</li>
<li><p><code>isLocalHost</code> exists for one reason: when you add brand enrichment later in this guide, you'll be sending domain names to an external API. <code>localhost:5173</code> or <code>192.168.1.50</code> are meaningless to that API and would just be wasted lookups, so it's better to filter them here, once, at the source. It checks for <code>localhost</code>, <code>127.0.0.1</code>, <code>.local</code> hostnames, and the standard private IPv4 ranges (<code>10.x.x.x</code>, <code>172.16.x.x</code>–<code>172.31.x.x</code>, <code>192.168.x.x</code>).</p>
</li>
<li><p><code>hashId</code> combines the URL and timestamp into a short, deterministic string using a simple hashing algorithm (djb2), so the same <code>(url, visitedAt)</code> pair always produces the same ID. This makes writes idempotent: re-running the backfill produces the <em>same</em> IDs for the <em>same</em> visits, so IndexedDB's <code>put</code> overwrites cleanly instead of duplicating, which is what makes "Scan my history" safe to click more than once.</p>
</li>
</ul>
<h3 id="heading-the-database-layer-so-far">The Database Layer (So Far)</h3>
<p>openloops stores everything in IndexedDB via the <a href="https://github.com/jakearchibald/idb"><code>idb</code></a> wrapper, which gives you a typed, promise-based API over the raw IndexedDB calls. Create <code>src/db/index.ts</code>:</p>
<pre><code class="language-typescript">import { openDB, type DBSchema, type IDBPDatabase } from "idb";
import type { RawEvent } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
}

const DB_NAME = "openloops";
const DB_VERSION = 1;

let _db: Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; | null = null;

export function getDB(): Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; {
  if (!_db) {
    _db = openDB&lt;OpenloopsDB&gt;(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
      },
    });
  }
  return _db;
}

export async function clearEvents(): Promise&lt;void&gt; {
  const db = await getDB();
  return db.clear("raw_events");
}

export async function putEvents(events: RawEvent[]): Promise&lt;void&gt; {
  if (events.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("raw_events", "readwrite");
  await Promise.all([...events.map((e) =&gt; tx.store.put(e)), tx.done]);
}

export async function getAllEvents(): Promise&lt;RawEvent[]&gt; {
  const db = await getDB();
  return db.getAllFromIndex("raw_events", "by_visitedAt");
}

export async function getEventCount(): Promise&lt;number&gt; {
  const db = await getDB();
  return db.count("raw_events");
}
</code></pre>
<p>Four small functions round out this first version of the database layer: <code>clearEvents</code> wipes the store, which the backfill calls first so every scan starts from a clean snapshot. <code>putEvents</code> writes a batch using IDB's <code>put</code>, which overwrites rather than duplicates. <code>getAllEvents</code> returns everything sorted by <code>visitedAt</code> via the index. And <code>getEventCount</code> returns a simple count for the dashboard.</p>
<p><code>_db</code> is a module-level singleton promise, so every part of the extension, the service worker and the dashboard alike, shares one connection. <code>DB_VERSION</code> starts at <code>1</code> here. As you add sessions, intent threads, and brand data in later parts, you'll add new stores guarded by <code>if (!db.objectStoreNames.contains(...))</code> and bump this number. That guard means existing users upgrade safely without touching stores that already exist.</p>
<h3 id="heading-capturing-new-visits-live">Capturing New Visits Live</h3>
<p>The service worker is the always-on part of the extension. Create <code>src/background.ts</code>:</p>
<pre><code class="language-typescript">import { hashId, extractDomain, isHttpUrl } from "./lib/util";
import { putEvents } from "./db/index";
import type { RawEvent } from "./types";

chrome.runtime.onInstalled.addListener(() =&gt; {
  console.log("[openloops] Extension installed.");
});

chrome.action.onClicked.addListener(() =&gt; {
  chrome.runtime.openOptionsPage();
});

const DEDUP_MS = 3_000;
const recentCaptures = new Map&lt;number, { url: string; at: number }&gt;();

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) =&gt; {
  if (changeInfo.status !== "complete" || !tab.url) return;

  const url = tab.url;

  if (!isHttpUrl(url)) return;

  const last = recentCaptures.get(tabId);
  const now = Date.now();
  if (last &amp;&amp; last.url === url &amp;&amp; now - last.at &lt; DEDUP_MS) {
    console.log(`[openloops] dedup skip — tab \({tabId} \){url}`);
    return;
  }

  recentCaptures.set(tabId, { url, at: now });

  const event: RawEvent = {
    id: hashId(url, now),
    url,
    domain: extractDomain(url),
    title: tab.title ?? url,
    visitedAt: now,
    source: "live",
  };

  putEvents([event]).then(() =&gt; {
    console.log(`[openloops] captured \({event.domain} — \){event.title}`);
  }).catch((err) =&gt; {
    console.error("[openloops] putEvents failed:", err);
  });
});
</code></pre>
<p><code>chrome.action.onClicked</code> is what makes the toolbar icon open the dashboard as a tab rather than a popup, working together with the <code>options_page</code> entry in your manifest.</p>
<p>Live capture happens inside the <code>tabs.onUpdated</code> listener, which Chrome fires repeatedly as a page loads, redirects, and updates its title, though you should only care about the moment <code>changeInfo.status === "complete"</code>. From there, <code>isHttpUrl</code> drops anything that isn't a real web page, the dedup guard collapses the duplicate "complete" events that SPAs love to fire, and the rest becomes a <code>RawEvent</code> with <code>source: "live"</code>.</p>
<p>That dedup guard is best-effort by design: <code>recentCaptures</code> is a plain in-memory <code>Map</code>, and Chrome can suspend the service worker between events, which wipes the <code>Map</code> along with it. It still collapses duplicate bursts within a single waking session, just not across service worker restarts, and that's an acceptable tradeoff since <code>hashId</code> already makes any duplicate that slips through harmless once it reaches IndexedDB.</p>
<p>The final write also looks slightly unusual: <code>putEvents([event]).then(...).catch(...)</code> instead of <code>await</code>. The listener doesn't need to block on the write finishing, and the service worker stays alive long enough to complete a single IndexedDB write even if it's about to be suspended, so firing the write and moving on is enough.</p>
<p>That <code>source</code> field carries more weight than it first appears, since it's how later code distinguishes "the user actually scanned their history" from "the extension has only been open for five minutes". This matters for onboarding when you design the dashboard later in this guide.</p>
<p>Build and reload the extension now (<code>npm run build</code>, then click the reload icon on the extension card in <code>chrome://extensions</code>), browse a few pages, then open the service worker's DevTools by clicking "service worker" on the extension card. You'll be able to see <code>[openloops] captured ...</code> log lines appear as confirmation that live capture is working.</p>
<h3 id="heading-backfilling-14-days-of-history">Backfilling 14 Days of History</h3>
<p>Live capture only sees what happens <em>after</em> you install the extension, so to make openloops useful immediately, you also need to backfill recent history. Create <code>src/pipeline/backfill.ts</code>:</p>
<pre><code class="language-typescript">import { extractDomain, hashId, isHttpUrl } from "../lib/util";
import { putEvents, clearEvents } from "../db/index";
import type { RawEvent } from "../types";

const CONCURRENCY = 50;

async function visitsForItem(
  item: chrome.history.HistoryItem,
  startTime: number
): Promise&lt;RawEvent[]&gt; {
  if (!item.url) return [];
  if (!isHttpUrl(item.url)) return [];

  const visits = await chrome.history.getVisits({ url: item.url });

  const events: RawEvent[] = [];
  for (const visit of visits) {
    if (!visit.visitTime || visit.visitTime &lt; startTime) continue;

    events.push({
      id: hashId(item.url, visit.visitTime),
      url: item.url,
      domain: extractDomain(item.url),
      title: item.title ?? item.url,
      visitedAt: visit.visitTime,
      source: "backfill",
    });
  }

  return events;
}

export async function backfillHistory(days = 14): Promise&lt;number&gt; {
  await clearEvents();

  const startTime = Date.now() - days * 24 * 60 * 60 * 1000;

  const historyItems = await chrome.history.search({
    text: "",
    startTime,
    maxResults: 100_000,
  });

  let totalWritten = 0;

  for (let i = 0; i &lt; historyItems.length; i += CONCURRENCY) {
    const batch = historyItems.slice(i, i + CONCURRENCY);
    const batchResults = await Promise.all(
      batch.map((item) =&gt; visitsForItem(item, startTime))
    );
    const events = batchResults.flat();
    await putEvents(events);
    totalWritten += events.length;
  }

  return totalWritten;
}
</code></pre>
<p><code>backfillHistory</code> starts by calling <code>clearEvents</code> and wiping the store so each run produces a clean snapshot for the chosen window. Every real visit still exists in <code>chrome.history</code>, so nothing is lost by starting over. It then searches with <code>maxResults: 100_000</code>, since the default of 100 is far too low for anyone with more than a few days of real browsing.</p>
<p>Each matching <code>HistoryItem</code> goes through <code>visitsForItem</code>, which skips items that Chrome returns with no <code>url</code> at all, a quirk of some deleted-history entries, and skips non-web URLs using <code>isHttpUrl</code>, before fetching that item's full visit list.</p>
<p>Calling <code>getVisits</code> here, instead of relying on <code>search</code> alone, matters because <code>chrome.history.search</code> is tempting as a single call, but it collapses every visit to a URL down to just the <em>most recent</em> one. If you visited the same Stack Overflow answer three times over two days while debugging something, <code>search</code> gives you one row, and in the next section, where you segment events into sessions, you need all three: that's the difference between "one visit, three days ago" and "a sustained debugging session."</p>
<p><code>getVisits</code> gives you that full timestamp list, but it returns <em>all</em> history for a URL regardless of date range, so <code>visitsForItem</code> filters by <code>startTime</code> itself. And because <code>chrome.history.search</code> can return tens of thousands of items for a heavy browser history, the backfill fans out to <code>getVisits</code> in batches of <code>CONCURRENCY</code>, set to 50, rather than firing everything at once. Chrome doesn't document a hard limit on concurrent <code>getVisits</code> calls, but 50 in flight at a time keeps things responsive without flooding it.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>You can verify live capture by browsing normally and watching <code>raw_events</code> fill up: open <code>chrome://extensions</code>, click "service worker" on the openloops card, then go to the <strong>Application</strong> tab → <strong>IndexedDB</strong> → <code>openloops</code> → <code>raw_events</code>, where each row should be a <code>RawEvent</code> with <code>source: "live"</code>.</p>
<p><code>backfillHistory</code> itself doesn't have a UI yet, but you'll wire it up to a "Scan my history" button when you build the dashboard rail in Part 13. For now, it's enough that it compiles and that <code>raw_events</code> is filling up from live capture. In the next part you'll start turning that raw stream into something structured: sessions.</p>
<h2 id="heading-how-to-turn-noise-into-sessions">How to Turn Noise into Sessions</h2>
<p>A real browsing history is full of activity that has nothing to do with what you were actually trying to do. An afternoon of research might be interleaved with dozens of visits to Gmail, Slack, or YouTube, along with pages whose titles are just "New Tab" or "Dashboard" because the page hadn't finished loading when the browser recorded it.</p>
<p>Before any of this can be grouped into something meaningful, two things need to happen: the noise needs to be filtered out, and what remains needs to be broken into sessions, contiguous stretches of activity separated by gaps in time.</p>
<p>This section builds both of those steps, along with a small keyword extractor that each session uses to describe what it was about, since that description is what later powers clustering.</p>
<h3 id="heading-filtering-out-noise">Filtering Out Noise</h3>
<p>Create <code>src/pipeline/noise.ts</code>:</p>
<pre><code class="language-typescript">import type { RawEvent } from "../types";
import { isHttpUrl, isLocalHost } from "../lib/util";

export const BLOCKED_DOMAINS: readonly string[] = [
  "mail.google.com",
  "outlook.live.com",
  "outlook.office.com",
  "calendar.google.com",
  "slack.com",
  "app.slack.com",
  "discord.com",
  "web.whatsapp.com",
  "teams.microsoft.com",
  "messenger.com",
];

export const ADULT_DOMAINS: readonly string[] = [
  "xvideos.com",
  "pornhub.com",
  "xnxx.com",
  "xhamster.com",
  "redtube.com",
  "youporn.com",
  "spankbang.com",
];

export const JUNK_DOMAINS: readonly string[] = [
  "trk.myperfect2give.com",
  "t.buenotraffic.com",
  "bwredir.com",
  "osom.saintscommunity.net",
];

const ALL_BLOCKED = [...BLOCKED_DOMAINS, ...ADULT_DOMAINS, ...JUNK_DOMAINS];

function domainIsBlocked(domain: string): boolean {
  return ALL_BLOCKED.some(
    (blocked) =&gt; domain === blocked || domain.endsWith("." + blocked)
  );
}

export const NOISE_TITLE_PREFIXES: readonly string[] = [
  "new tab",
  "new chat",
  "untitled",
  "inbox",
  "home",
  "dashboard",
  "sign in",
  "log in",
  "loading",
];

function titleIsGeneric(title: string, domain: string): boolean {
  if (title.trim() === "") return true;
  if (title.toLowerCase() === domain.toLowerCase()) return true;

  const lower = title.toLowerCase();
  return NOISE_TITLE_PREFIXES.some((prefix) =&gt; lower.startsWith(prefix));
}

export function isNoise(event: RawEvent): boolean {
  if (!isHttpUrl(event.url)) return true;
  if (isLocalHost(event.domain)) return true;
  return domainIsBlocked(event.domain) || titleIsGeneric(event.title, event.domain);
}
</code></pre>
<p><code>isNoise</code> is the single function the rest of the pipeline calls, and it layers four checks on top of each other, each one catching a different kind of noise.</p>
<p>The first two checks reuse the helpers from earlier: <code>isHttpUrl</code> and <code>isLocalHost</code> drop anything that isn't a real web page or that points at a local development server, the same filters that already protect capture. Checking them again here is a deliberate belt-and-suspenders measure: if anything ever reaches <code>raw_events</code> without having passed through capture's checks, it still can't make it into a session.</p>
<p><code>BLOCKED_DOMAINS</code> covers communication and productivity tools, Gmail, Slack, Discord, WhatsApp Web, and similar. Those tools that you visit constantly but that carry no research intent of their own. <code>domainIsBlocked</code> matches both the exact domain and any subdomain, so <code>slack.com</code> in the list also catches <code>app.slack.com</code>. <code>ADULT_DOMAINS</code> and <code>JUNK_DOMAINS</code> exist for related reasons, keeping adult content and known tracker or redirect domains out of your threads entirely.</p>
<p><code>BLOCKED_DOMAINS</code> is a curated, static list, and later in this guide it's complemented by a second, frequency-based detector in <code>ambient.ts</code>. This drops any domain that shows up in nearly every session regardless of what that domain actually is.</p>
<p>The last check, <code>titleIsGeneric</code>, catches pages whose titles tell you nothing useful: an empty title, a title that's identical to the domain name, or a title that starts with a generic prefix like "New Tab", "Dashboard", "Loading...", or "Sign in". <code>NOISE_TITLE_PREFIXES</code> is matched against the start of the lowercased title, so "Dashboard | Vercel" gets dropped right alongside a bare "Dashboard", while a content-rich title on that same domain passes through untouched.</p>
<h3 id="heading-extracting-keywords">Extracting Keywords</h3>
<p>Create <code>src/pipeline/keywords.ts</code>. This isn't NLP, just frequency counting after stopword removal. This is good enough to surface something like "typescript generics" or "react hooks" from a session of related browsing:</p>
<pre><code class="language-typescript">import { BLOCKED_DOMAINS } from "./noise";

export const STOPWORDS: ReadonlySet&lt;string&gt; = new Set([
  "the", "and", "for", "with", "you", "your", "how", "what", "this", "that",
  "from", "are", "was", "not", "but", "all", "can", "has", "have", "will",
  "its", "out", "one", "get", "our", "had", "just", "about", "also", "more",
  "into", "than", "then", "when", "their", "there", "which", "would", "been",
  "his", "her", "who", "they", "she", "him", "now", "any", "way", "use",
  "using", "used", "make", "made",
  "google", "youtube", "search", "chat", "new", "home", "www", "com", "org",
  "net", "page", "site", "tab", "view", "app", "log", "sign", "login",
  "official", "free", "online", "best", "top", "open",
]);

export const PLATFORM_STOPWORDS: ReadonlySet&lt;string&gt; = new Set([
  "instagram", "facebook", "youtube", "claude", "google", "linkedin",
  "twitter", "reddit", "netflix", "amazon", "gmail", "whatsapp", "tiktok",
  "messenger",
  "stories", "story", "reel", "reels", "shorts", "short", "feed", "watch",
  "video", "videos", "music", "post", "posts", "message", "messages",
  "dm", "dms", "notification", "notifications", "profile", "home", "login",
  "signin", "follow", "followers",
]);

function derivedDomainLabels(): Set&lt;string&gt; {
  const labels = new Set&lt;string&gt;();
  for (const domain of BLOCKED_DOMAINS) {
    const label = domain.split(".").at(-2);
    if (label) labels.add(label);
  }
  return labels;
}

const ALL_STOP_TOKENS: ReadonlySet&lt;string&gt; = new Set([
  ...STOPWORDS,
  ...PLATFORM_STOPWORDS,
  ...derivedDomainLabels(),
]);

export function extractKeywords(titles: string[], max = 8): string[] {
  const freq = new Map&lt;string, number&gt;();

  for (const title of titles) {
    const tokens = title.toLowerCase().split(/[^a-z0-9]+/);
    for (const token of tokens) {
      if (token.length &lt; 3) continue;
      if (/^\d+$/.test(token)) continue;
      if (ALL_STOP_TOKENS.has(token)) continue;

      freq.set(token, (freq.get(token) ?? 0) + 1);
    }
  }

  return [...freq.entries()]
    .sort((a, b) =&gt; b[1] - a[1])
    .slice(0, max)
    .map(([token]) =&gt; token);
}
</code></pre>
<p><code>extractKeywords</code> takes the page titles from a group of events and returns the handful of words that show up most often, after stripping out everything that isn't a topic. That stripping is doing more work than the name "stopwords" suggests.</p>
<p><code>STOPWORDS</code> covers common English function words like "the" and "with", plus generic site chrome like "search", "login", and "page". On its own, this would still let through tokens like "instagram" or "reels" from a title such as "Reels · Instagram", and those tokens would then show up as keywords for that session.</p>
<p>That gap is what <code>PLATFORM_STOPWORDS</code> closes. A title like "Reels · Instagram" or "Watch - YouTube" identifies the tool you were using, not what you were doing with it. So <code>PLATFORM_STOPWORDS</code> strips out platform and brand names along with social media UI chrome like "stories", "feed", "dm", and "notifications". Without this list, sessions on social platforms would extract keywords like "instagram" or "watch". Those would become thread titles that quietly pull unrelated sessions together during clustering, since every social-media session would share that one meaningless keyword.</p>
<p><code>derivedDomainLabels</code> keeps a third source of stopwords in sync automatically: for every domain in <code>BLOCKED_DOMAINS</code>, it takes the label immediately before the top-level domain. So <code>mail.google.com</code> becomes <code>google</code> and <code>web.whatsapp.com</code> becomes <code>whatsapp</code>. Adding a new domain to that blocklist later also prevents its name from polluting keywords, without any extra bookkeeping.</p>
<p>With all three sets merged once at module load into <code>ALL_STOP_TOKENS</code>, <code>extractKeywords</code> itself is straightforward: lowercase every title, split on anything that isn't a letter or digit, drop tokens shorter than three characters or made entirely of digits, and drop anything in <code>ALL_STOP_TOKENS</code>. Then count what's left and return the most frequent entries.</p>
<h3 id="heading-extending-the-database-for-sessions">Extending the Database For Sessions</h3>
<p>Sessions need a place to live. Earlier in this guide, <code>src/db/index.ts</code> defined a schema with just <code>raw_events</code> at version 1. We'll add a <code>sessions</code> store and bump the version to 2.</p>
<p>First, extend the schema and the <code>upgrade</code> callback:</p>
<pre><code class="language-typescript">import type { RawEvent, Session } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
  sessions: {
    key: string;
    value: Session;
    indexes: { by_startedAt: number };
  };
}

const DB_VERSION = 2;

export function getDB(): Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; {
  if (!_db) {
    _db = openDB&lt;OpenloopsDB&gt;(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
        if (!db.objectStoreNames.contains("sessions")) {
          const s = db.createObjectStore("sessions", { keyPath: "id" });
          s.createIndex("by_startedAt", "startedAt");
        }
      },
    });
  }
  return _db;
}
</code></pre>
<p>Then add the helper functions sessions need, alongside the <code>raw_events</code> helpers you already wrote. They follow the same shape: <code>putSessions</code> writes a batch idempotently, <code>clearSessions</code> wipes the store before a rebuild, <code>getAllSessions</code> returns everything sorted by <code>startedAt</code> via the index, and <code>getSessionCount</code> returns a total.</p>
<pre><code class="language-typescript">export async function putSessions(sessions: Session[]): Promise&lt;void&gt; {
  if (sessions.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("sessions", "readwrite");
  await Promise.all([...sessions.map((s) =&gt; tx.store.put(s)), tx.done]);
}

export async function clearSessions(): Promise&lt;void&gt; {
  const db = await getDB();
  return db.clear("sessions");
}

export async function getAllSessions(): Promise&lt;Session[]&gt; {
  const db = await getDB();
  return db.getAllFromIndex("sessions", "by_startedAt");
}

export async function getSessionCount(): Promise&lt;number&gt; {
  const db = await getDB();
  return db.count("sessions");
}
</code></pre>
<p>The <code>if (!db.objectStoreNames.contains(...))</code> guard from earlier is what makes this safe: anyone who already has a version-1 database, with <code>raw_events</code> full of real data, gets the new <code>sessions</code> store added on top, without touching what's already there.</p>
<h3 id="heading-segmenting-events-into-sessions">Segmenting Events into Sessions</h3>
<p>A session is a contiguous block of browsing activity, with a new one starting whenever the gap between two consecutive events exceeds <code>SESSION_GAP_MS</code>. Create <code>src/pipeline/sessions.ts</code>:</p>
<pre><code class="language-typescript">import { getAllEvents, clearSessions, putSessions } from "../db/index";
import { isNoise } from "./noise";
import { extractKeywords } from "./keywords";
import { hashId } from "../lib/util";
import type { RawEvent, Session } from "../types";

const SESSION_GAP_MS = 30 * 60 * 1000;

function rankDomains(events: RawEvent[]): string[] {
  const freq = new Map&lt;string, number&gt;();
  for (const e of events) {
    freq.set(e.domain, (freq.get(e.domain) ?? 0) + 1);
  }
  return [...freq.entries()]
    .sort((a, b) =&gt; b[1] - a[1])
    .map(([domain]) =&gt; domain);
}

function buildSession(events: RawEvent[]): Session {
  const startedAt = events[0].visitedAt;
  const endedAt = events[events.length - 1].visitedAt;

  return {
    id: hashId(events[0].url, startedAt),
    events,
    startedAt,
    endedAt,
    domains: rankDomains(events),
    keywords: extractKeywords(events.map((e) =&gt; e.title)),
  };
}

export async function buildSessions(): Promise&lt;{ events: number; sessions: number }&gt; {
  const allEvents = await getAllEvents();

  const meaningful = allEvents.filter((e) =&gt; !isNoise(e));

  if (meaningful.length === 0) {
    await clearSessions();
    return { events: 0, sessions: 0 };
  }

  const sessions: Session[] = [];
  let currentGroup: RawEvent[] = [meaningful[0]];

  for (let i = 1; i &lt; meaningful.length; i++) {
    const gap = meaningful[i].visitedAt - meaningful[i - 1].visitedAt;

    if (gap &gt; SESSION_GAP_MS) {
      sessions.push(buildSession(currentGroup));
      currentGroup = [meaningful[i]];
    } else {
      currentGroup.push(meaningful[i]);
    }
  }
  sessions.push(buildSession(currentGroup));

  const substantive = sessions.filter(
    (s) =&gt; !(s.events.length === 1 &amp;&amp; s.keywords.length === 0)
  );

  await clearSessions();
  await putSessions(substantive);

  return { events: meaningful.length, sessions: substantive.length };
}
</code></pre>
<p><code>buildSessions</code> does five things in order:</p>
<ol>
<li><p>loads every raw event sorted by time,</p>
</li>
<li><p>drops anything <code>isNoise</code> flags,</p>
</li>
<li><p>walks the remaining list and starts a new session whenever the gap between two consecutive events exceeds <code>SESSION_GAP_MS</code> (pushing the final in-progress group once the loop ends since nothing else closes it off),</p>
</li>
<li><p>drops sessions that turned out to be a single event with no extractable keywords (usually stray page loads that never connected to anything else),</p>
</li>
<li><p>and persists the result.</p>
</li>
</ol>
<p>Each session's <code>domains</code> and <code>keywords</code> come from <code>rankDomains</code> and <code>extractKeywords</code> running over just the events in that group. <code>rankDomains</code> counts how many events came from each domain and orders them by frequency, so the most-visited domain in a session comes first.</p>
<p>A worked example makes "walking the list" concrete. Take five events that survive noise filtering, A through E:</p>
<pre><code class="language-plaintext">A  t= 0 min  "TypeScript generics - Stack Overflow"   stackoverflow.com
B  t= 5 min  "TypeScript Handbook"                    typescriptlang.org
C  t=10 min  "microsoft/TypeScript - GitHub"          github.com
   ↑ gap to D = 45 min  &gt;  SESSION_GAP_MS (30 min)  → SPLIT HERE
D  t=55 min  "React hooks explained - YouTube"         youtube.com
E  t=60 min  "useEffect cleanup - Stack Overflow"     stackoverflow.com
</code></pre>
<p>As the loop walks from A to B to C, each gap is under the 30-minute limit, so all three stay in the same group. The jump from C to D is 45 minutes, which crosses <code>SESSION_GAP_MS</code>, so the loop closes off <code>[A, B, C]</code> as Session 1 and starts a fresh group with D. From D to E is only 5 minutes, so E joins D, and that group becomes Session 2 once the loop ends.</p>
<p>Session 1 ends up tagged with keywords like <code>typescript</code> and <code>generics</code>, while Session 2 is tagged with <code>react</code> and <code>hooks</code>, even though both sessions happened on the same day.</p>
<p><code>SESSION_GAP_MS</code> is set to 30 minutes because that's the same default that Google Analytics and similar tools use, and it works well for most browsing patterns.</p>
<p>The tradeoff runs in both directions: a shorter gap produces more, smaller sessions, which gives clustering a more granular signal but risks fragmenting one continuous task into several pieces. A longer gap produces fewer, larger sessions, which risks merging activity that was actually unrelated.</p>
<p>30 minutes is a reasonable starting point, and it's the kind of constant you can come back and tune once you see how your own threads turn out.</p>
<h3 id="heading-checkpoint"><strong>Checkpoint</strong></h3>
<p><code>buildSessions</code> doesn't have a UI yet either. It'll get wired up to a "Build sessions" button alongside "Scan my history" when you design the dashboard later in this guide.</p>
<p>For now, the goal is just for everything in this section to compile cleanly: <code>src/pipeline/noise.ts</code>, <code>src/pipeline/keywords.ts</code>, the updated <code>src/db/index.ts</code>, and <code>src/pipeline/sessions.ts</code> should all build without errors. <code>getDB()</code> should report version 2 the next time the extension reloads (visible in DevTools under <strong>Application</strong> → <strong>IndexedDB</strong> → <code>openloops</code>, where the database now lists both <code>raw_events</code> and <code>sessions</code> as object stores).</p>
<p>With sessions in place, the next section takes this structured-but-unconnected data and groups sessions together into the intent threads this whole project is named after.</p>
<h2 id="heading-how-to-cluster-sessions-into-intent-threads">How to Cluster Sessions into Intent Threads</h2>
<p>Sessions group events that happened close together in time. But the things you're actually trying to do rarely fit inside one session. Comparing laptops might span three sessions over four days. A question you keep meaning to look into might surface for ten minutes every few days for two weeks.</p>
<p>This section groups related sessions together into intent threads, then scores each thread for how confident openloops is that it represents something real and how alive it still is.</p>
<p>Two files do this work. <code>src/pipeline/ambient.ts</code> detects domains that are part of your daily routine rather than any particular intent, so they don't create false similarity between unrelated sessions. <code>src/pipeline/threads.ts</code> does the actual clustering and scoring.</p>
<h3 id="heading-detecting-ambient-domains">Detecting Ambient Domains</h3>
<p>Some domains show up in almost every session regardless of what you're doing: <a href="http://youtube.com">youtube.com</a> as background noise, <a href="http://github.com">github.com</a> if you're a developer who commits daily, or <a href="http://claude.ai">claude.ai</a> if you use it as a general assistant. If clustering compared sessions on these domains the same way it compares them on anything else, two completely unrelated sessions would look similar just because they both touched <a href="http://youtube.com">youtube.com</a>, and everything would eventually merge into one enormous thread.</p>
<p><code>ambient.ts</code> solves this with a frequency check: a domain is ambient if it shows up on a large enough fraction of your active days, regardless of topic.</p>
<p>Create <code>src/pipeline/ambient.ts</code>:</p>
<pre><code class="language-typescript">import type { Session } from "../types";

export const UBIQUITY_THRESHOLD = 0.6;
export const MIN_ACTIVE_DAYS = 3;

function toDay(epochMs: number): string {
  return new Date(epochMs).toDateString();
}

export function detectAmbientDomains(sessions: Session[]): Set&lt;string&gt; {
  const allEvents = sessions.flatMap((s) =&gt; s.events);

  const activeDays = new Set(allEvents.map((e) =&gt; toDay(e.visitedAt)));
  const totalActiveDays = activeDays.size;

  if (totalActiveDays &lt; MIN_ACTIVE_DAYS) {
    return new Set();
  }

  const domainDayMap = new Map&lt;string, Set&lt;string&gt;&gt;();
  for (const event of allEvents) {
    const day = toDay(event.visitedAt);
    if (!domainDayMap.has(event.domain)) {
      domainDayMap.set(event.domain, new Set());
    }
    domainDayMap.get(event.domain)!.add(day);
  }

  const ambient = new Set&lt;string&gt;();
  for (const [domain, days] of domainDayMap) {
    const ubiquity = days.size / totalActiveDays;
    if (ubiquity &gt;= UBIQUITY_THRESHOLD) {
      ambient.add(domain);
      console.log(
        `[openloops] ambient: \({domain} (\){days.size}/\({totalActiveDays} days, ubiquity=\){ubiquity.toFixed(2)})`
      );
    }
  }

  return ambient;
}
</code></pre>
<p><code>toDay</code> collapses a timestamp down to a calendar-day string, so two events on the same day produce the same key, regardless of the exact time.</p>
<p><code>detectAmbientDomains</code> first counts how many distinct days had any browsing activity at all – that's <code>totalActiveDays</code> – then builds a map from each domain to the set of days it appeared on. A domain's ubiquity is <code>days.size / totalActiveDays</code>, the fraction of your active days that domain showed up on. Anything at or above <code>UBIQUITY_THRESHOLD</code> 0.6 gets added to the returned set.</p>
<p><code>MIN_ACTIVE_DAYS</code> exists because with only one or two days of data, almost every domain you visited would technically appear on 100% of your active days, and the detector would mark everything as ambient. Below three active days, it returns an empty set and skips detection entirely.</p>
<p>This approach has a real tradeoff. It correctly identifies genuinely ambient tools, but it can also suppress a domain you happened to research intensively every single day for a week, which would also cross the 60% threshold.</p>
<p><code>UBIQUITY_THRESHOLD</code> is the knob for that tradeoff: raising it reduces false positives at the cost of letting some real ambient noise back in.</p>
<h3 id="heading-extending-the-database-for-intent-threads">Extending the Database for Intent Threads</h3>
<p>Threads need their own store. Bump <code>DB_VERSION</code> to 3 and add <code>intent_threads</code>, indexed by <code>lastSeen</code>, so the dashboard can show the most recently active threads first:</p>
<pre><code class="language-typescript">import type { RawEvent, Session, IntentThread } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
  sessions: {
    key: string;
    value: Session;
    indexes: { by_startedAt: number };
  };
  intent_threads: {
    key: string;
    value: IntentThread;
    indexes: { by_lastSeen: number };
  };
}

const DB_VERSION = 3;

export function getDB(): Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; {
  if (!_db) {
    _db = openDB&lt;OpenloopsDB&gt;(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
        if (!db.objectStoreNames.contains("sessions")) {
          const s = db.createObjectStore("sessions", { keyPath: "id" });
          s.createIndex("by_startedAt", "startedAt");
        }
        if (!db.objectStoreNames.contains("intent_threads")) {
          const s = db.createObjectStore("intent_threads", { keyPath: "id" });
          s.createIndex("by_lastSeen", "lastSeen");
        }
      },
    });
  }
  return _db;
}
</code></pre>
<p>Then add the matching helpers:</p>
<pre><code class="language-typescript">export async function putThreads(threads: IntentThread[]): Promise&lt;void&gt; {
  if (threads.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("intent_threads", "readwrite");
  await Promise.all([...threads.map((t) =&gt; tx.store.put(t)), tx.done]);
}

export async function clearThreads(): Promise&lt;void&gt; {
  const db = await getDB();
  return db.clear("intent_threads");
}

export async function getAllThreads(): Promise&lt;IntentThread[]&gt; {
  const db = await getDB();
  const index = db
    .transaction("intent_threads", "readonly")
    .store.index("by_lastSeen");

  let cursor = await index.openCursor(null, "prev");
  const results: IntentThread[] = [];
  while (cursor) {
    results.push(cursor.value);
    cursor = await cursor.continue();
  }
  return results;
}

export async function getThreadCount(): Promise&lt;number&gt; {
  const db = await getDB();
  return db.count("intent_threads");
}
</code></pre>
<p><code>putThreads</code>, <code>clearThreads</code>, and <code>getThreadCount</code> follow the same pattern as the <code>sessions</code> helpers from earlier. <code>getAllThreads</code> is the odd one out: instead of <code>getAllFromIndex</code>, which only returns ascending order, it opens a cursor on <code>by_lastSeen</code> in <code>"prev"</code> direction and walks it manually. That gives you threads ordered with the most recently active first, the order the dashboard wants for status-grouped cards.</p>
<h3 id="heading-clustering-sessions-into-threads">Clustering Sessions into Threads</h3>
<p>With ambient domains identified, <code>src/pipeline/threads.ts</code> now does the real work: grouping sessions into threads, then scoring and classifying each one.</p>
<p>The approach is <a href="https://research.google/blog/scaling-hierarchical-agglomerative-clustering-to-trillion-edge-graphs/">greedy agglomerative clustering</a>. Walk through sessions in chronological order, and for each one, either merge it into the most similar existing thread or start a new thread if nothing is similar enough.</p>
<p>Start with the imports, the tuning constants, and the similarity calculation:</p>
<pre><code class="language-typescript">import { getAllSessions, clearThreads, putThreads } from "../db/index";
import { detectAmbientDomains } from "./ambient";
import { hashId } from "../lib/util";
import type { Session, IntentThread } from "../types";

export const SIMILARITY_THRESHOLD = 0.15;
export const DOMAIN_WEIGHT = 0.5;
export const KEYWORD_WEIGHT = 0.5;

interface ThreadBuilder {
  id: string;
  sessions: Session[];
  domainSet: Set&lt;string&gt;;
  keywordSet: Set&lt;string&gt;;
}

function jaccard(a: Set&lt;string&gt;, b: Set&lt;string&gt;): number {
  if (a.size === 0 &amp;&amp; b.size === 0) return 0;
  let intersection = 0;
  for (const item of a) {
    if (b.has(item)) intersection++;
  }
  const union = a.size + b.size - intersection;
  return intersection / union;
}

function similarity(
  session: Session,
  thread: ThreadBuilder,
  ambient: Set&lt;string&gt;
): number {
  const sessionDomains  = new Set(session.domains.filter((d) =&gt; !ambient.has(d)));
  const threadDomains   = new Set([...thread.domainSet].filter((d) =&gt; !ambient.has(d)));
  const sessionKeywords = new Set(session.keywords);

  const domainScore   = jaccard(sessionDomains, threadDomains);
  const keywordScore  = jaccard(sessionKeywords, thread.keywordSet);

  return DOMAIN_WEIGHT * domainScore + KEYWORD_WEIGHT * keywordScore;
}
</code></pre>
<p><code>ThreadBuilder</code> is a mutable accumulator used only during clustering: a thread in progress, with its sessions plus the union of all domains and keywords seen so far. <code>jaccard</code> is the standard set-similarity measure, the size of the intersection divided by the size of the union, returning 0 for two empty sets rather than dividing zero by zero.</p>
<p><code>similarity</code> compares one candidate session against one in-progress thread. Before comparing domains, it filters ambient domains out of both sides, so a shared <code>youtube.com</code> never contributes to the score. It then computes a domain Jaccard score and a keyword Jaccard score separately, and combines them with <code>DOMAIN_WEIGHT</code> and <code>KEYWORD_WEIGHT</code>, both 0.5, giving domain overlap and keyword overlap equal say in the final number.</p>
<p>Next, the clustering loop itself:</p>
<pre><code class="language-typescript">function clusterSessions(
  sessions: Session[],
  ambient: Set&lt;string&gt;
): ThreadBuilder[] {
  const threads: ThreadBuilder[] = [];

  for (const session of sessions) {
    let bestThread: ThreadBuilder | null = null;
    let bestScore = 0;

    for (const thread of threads) {
      const score = similarity(session, thread, ambient);
      if (score &gt; bestScore) {
        bestScore = score;
        bestThread = thread;
      }
    }

    if (bestThread &amp;&amp; bestScore &gt;= SIMILARITY_THRESHOLD) {
      bestThread.sessions.push(session);
      for (const d of session.domains)  bestThread.domainSet.add(d);
      for (const k of session.keywords) bestThread.keywordSet.add(k);
    } else {
      threads.push({
        id: hashId(session.id, session.startedAt),
        sessions: [session],
        domainSet:  new Set(session.domains),
        keywordSet: new Set(session.keywords),
      });
    }
  }

  return threads;
}
</code></pre>
<p><code>clusterSessions</code> relies on <code>sessions</code> already being sorted chronologically, which <code>getAllSessions</code> guarantees via its index. For each session, it scores against every thread built so far and keeps the best match.</p>
<p>If that best score clears <code>SIMILARITY_THRESHOLD</code>, the session merges in and its domains and keywords get folded into the thread's accumulated sets. This means that later sessions are compared against the thread's <em>entire</em> accumulated history rather than only its seed session. If nothing clears the threshold, the session becomes the seed of a brand-new thread.</p>
<p>A worked example shows how this plays out. Suppose <code>detectAmbientDomains</code> returned <code>{ youtube.com }</code>, and three sessions arrive in this order:</p>
<pre><code class="language-plaintext">S1: domains=[stackoverflow.com, typescriptlang.org]
    keywords=[typescript, generics, interface, mapped]

S2: domains=[stackoverflow.com, typescriptlang.org, github.com]
    keywords=[typescript, generics, utility, types]

S3: domains=[python.org, docs.python.org]
    keywords=[python, async, await, coroutine]
</code></pre>
<p>S1 arrives first. With no threads yet, it seeds Thread A: <code>domainSet = {stackoverflow.com, typescriptlang.org}</code>, <code>keywordSet = {typescript, generics, interface, mapped}</code>.</p>
<p>S2 is scored against Thread A. Neither set contains the ambient <code>youtube.com</code>, so nothing gets filtered out. The domain Jaccard is <code>|{stackoverflow.com, typescriptlang.org}| / |{stackoverflow.com, typescriptlang.org, github.com}|</code>, or 2/3 ≈ 0.667. The keyword Jaccard is <code>|{typescript, generics}| / |{typescript, generics, interface, mapped, utility, types}|</code>, or 2/6 ≈ 0.333. The combined similarity is <code>0.5 × 0.667 + 0.5 × 0.333 = 0.5</code>, comfortably above <code>SIMILARITY_THRESHOLD</code> (0.15), so S2 merges into Thread A, whose sets grow to include <code>github.com</code>, <code>utility</code>, and <code>types</code>.</p>
<p>S3 is scored against Thread A. There's no overlap at all between <code>{python.org, docs.python.org}</code> and Thread A's domains, or between their keyword sets, so both Jaccard scores are 0 and the combined similarity is 0. That's below the threshold, so S3 seeds a new Thread B.</p>
<p>The result: Thread A holds the TypeScript research across two sessions, and Thread B holds the Python session on its own.</p>
<p><code>SIMILARITY_THRESHOLD</code> is the single most consequential constant in this file, and 0.15 is lower than you might guess for a 50/50 weighted Jaccard score. A starting value like 0.3 sounds more principled. That would mean two sessions need to share roughly a third of their combined domains and keywords before they're considered part of the same thread.</p>
<p>Run that against real, messy browsing history, though, and it produces far too many threads: sessions that were obviously part of the same research, but didn't share quite enough keywords to clear 0.3, end up scattered across separate threads.</p>
<p>Dropping the threshold to 0.15 lets sessions merge on weaker but still real signal. Two sessions sharing just one domain and one keyword out of several can already cross 0.15, and the result is fewer, more coherent threads that actually match what the browsing history looks like.</p>
<p>This is the kind of constant you tune empirically rather than deriving it from first principles: build your threads, look at the result, and adjust.</p>
<p><code>buildThreads</code>, covered next, prints a table of every thread's title, type, status, confidence, and top keywords specifically so you can eyeball this. If two threads obviously belong together, lower <code>SIMILARITY_THRESHOLD</code>. If one thread is clearly several unrelated topics glued together, raise it.</p>
<h3 id="heading-scoring-and-classifying-threads">Scoring and Classifying Threads</h3>
<p>Clustering produces groups of sessions, but a group of sessions isn't yet an <code>IntentThread</code>. The rest of <code>threads.ts</code> turns each group into something with a type, a confidence score, a status, and a set of human-readable signals explaining why.</p>
<p>A few small helpers come first:</p>
<pre><code class="language-typescript">export const BUYING_WORDS: readonly string[] = [
  "vs", "versus", "alternative", "alternatives",
  "comparison", "pricing", "price", "review", "reviews", "best",
];

export const LEARNING_WORDS: readonly string[] = [
  "how to", "tutorial", "tutorials", "docs", "documentation",
  "guide", "learn", "example", "examples", "crash course", "introduction",
];

const STATUS_ACTIVE_MS  = 48 * 60 * 60 * 1000;
const STATUS_STALLED_MS = 7  * 24 * 60 * 60 * 1000;

function toTitleCase(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

function findMatches(titles: string[], wordList: readonly string[]): string[] {
  const lower = titles.map((t) =&gt; t.toLowerCase());
  const found = new Set&lt;string&gt;();

  for (const word of wordList) {
    const isPhrase = word.includes(" ");
    for (const title of lower) {
      if (isPhrase) {
        if (title.includes(word)) found.add(word);
      } else {
        const tokens = title.split(/[^a-z0-9]+/);
        if (tokens.includes(word)) found.add(word);
      }
    }
  }

  return [...found];
}

function toCalendarDay(epochMs: number): string {
  return new Date(epochMs).toDateString();
}
</code></pre>
<p><code>BUYING_WORDS</code> and <code>LEARNING_WORDS</code> are small vocabularies that signal intent. <code>findMatches</code> checks a list of page titles against one of these vocabularies, and handles single words and phrases differently: a multi-word entry like "how to" is checked as a substring, since it's specific enough that false positives are unlikely. But a single word like "review" is checked as a whole token, split out of the title on non-alphanumeric characters.</p>
<p>Without that distinction, "review" would match inside "overview" too, which would misclassify any thread that happened to involve an "Overview" page. <code>toTitleCase</code> and <code>toCalendarDay</code> are small formatting helpers used by the scoring function next.</p>
<p>That scoring function, <code>scoreThread</code>, is the longest function in the project, since it's where every signal collected so far gets turned into the fields on <code>IntentThread</code>:</p>
<pre><code class="language-typescript">function scoreThread(builder: ThreadBuilder): IntentThread {
  const { sessions, keywordSet } = builder;

  const firstSeen  = sessions[0].startedAt;
  const lastSeen   = sessions[sessions.length - 1].endedAt;

  const allEvents  = sessions.flatMap((s) =&gt; s.events);
  const totalEvents = allEvents.length;
  const daySet     = new Set(allEvents.map((e) =&gt; toCalendarDay(e.visitedAt)));
  const distinctDays = daySet.size;

  const allTitles      = allEvents.map((e) =&gt; e.title);
  const buyingMatches  = findMatches(allTitles, BUYING_WORDS);
  const learningMatches = findMatches(allTitles, LEARNING_WORDS);

  let type: IntentThread["type"];
  if (buyingMatches.length &gt; 0) {
    type = "buying";
  } else if (learningMatches.length &gt; 0) {
    type = "learning";
  } else if (distinctDays &gt; 5 &amp;&amp; sessions.length &gt;= 3) {
    type = "planning";
  } else if (totalEvents &gt;= 3) {
    type = "research";
  } else {
    type = "unclassified";
  }

  const age = Date.now() - lastSeen;
  const status: IntentThread["status"] =
    age &lt; STATUS_ACTIVE_MS  ? "active"  :
    age &lt; STATUS_STALLED_MS ? "stalled" :
    "dormant";

  const confidence = parseFloat((
    Math.min(distinctDays / 5, 1) * 0.35 +
    Math.min(sessions.length / 5, 1) * 0.25 +
    Math.min(totalEvents / 20, 1)  * 0.20 +
    (type !== "unclassified" ? 1 : 0)  * 0.20
  ).toFixed(2));

  const signals: string[] = [];

  if (distinctDays &gt; 1)
    signals.push(`revisited across ${distinctDays} days`);
  if (type === "buying" &amp;&amp; buyingMatches.length &gt; 0)
    signals.push(`comparison language: ${buyingMatches.join(", ")}`);
  if (type === "learning" &amp;&amp; learningMatches.length &gt; 0)
    signals.push(`learning language: ${learningMatches.join(", ")}`);
  signals.push(`\({sessions.length} session\){sessions.length !== 1 ? "s" : ""}`);
  if (totalEvents &gt; 5)
    signals.push(`${totalEvents} total events`);
  if (type === "planning")
    signals.push("sustained activity across many days");

  const ageDays = Math.floor(age / (24 * 60 * 60 * 1000));
  if (ageDays === 0)       signals.push("last active today");
  else if (ageDays === 1)  signals.push("last active yesterday");
  else                     signals.push(`last active ${ageDays} days ago`);

  const title =
    [...keywordSet].slice(0, 3).map(toTitleCase).join(" ") || "Untitled Thread";

  return {
    id: builder.id,
    title,
    sessions,
    type,
    confidence,
    status,
    firstSeen,
    lastSeen,
    distinctDays,
    signals,
  };
}
</code></pre>
<p>There's a lot here, so it's worth walking through each field on <code>IntentThread</code> in the order it's computed.</p>
<p><code>firstSeen</code> and <code>lastSeen</code> come straight from the boundary sessions, since <code>sessions</code> arrives in chronological order from clustering. <code>distinctDays</code> reuses the same calendar-day collapsing as <code>ambient.ts</code>. This time it counts how many different days <em>this thread's</em> events span, regardless of how many total active days you had overall.</p>
<p>Classification into <code>type</code> is a cascade, and the order matters. Comparison language (<code>BUYING_WORDS</code>) is checked first, because a thread where you're comparing two frameworks is "buying" even if it also contains tutorial pages. Comparison intent is the stronger signal.</p>
<p>Learning language comes next. After that, <code>planning</code> is reserved for threads that span more than five distinct days <em>and</em> have at least three sessions of sustained, recurring activity rather than a single deep dive.</p>
<p><code>research</code> is the catch-all for anything with at least three events that didn't match anything more specific, and <code>unclassified</code> is what's left, usually threads with too little activity to say anything confident about.</p>
<p><code>status</code> is purely a function of how long ago <code>lastSeen</code> was: under 48 hours is <code>active</code>, under 7 days is <code>stalled</code>, anything older is <code>dormant</code>.</p>
<p><code>confidence</code> is a weighted sum of four signals, each normalized to a maximum of 1 before weighting, so the total can't exceed 1 either. <code>distinctDays / 5</code>, capped at 1, contributes up to 35%, treating five or more distinct days as fully confident on that axis. <code>sessions.length / 5</code>, capped at 1, contributes up to 25%. <code>totalEvents / 20</code>, capped at 1, contributes up to 20%. And whether <code>type</code> is anything other than <code>unclassified</code> contributes the final 20% as an all-or-nothing bonus.</p>
<p>A thread revisited across five-plus days, across five-plus sessions, with twenty-plus events, that also classified cleanly, scores a full 1.0. A thread that's a single session with two events and no classification scores close to 0.</p>
<p><code>signals</code> is a plain-English audit trail for the confidence score and status: it explains why a thread looks the way it does, listing things like how many days it was revisited across, what comparison or learning language was found, the session and event counts, and how recently it was last active. The dashboard surfaces these directly.</p>
<p>Finally, <code>title</code> is a placeholder: the top three keywords from the thread's accumulated <code>keywordSet</code>, title-cased and joined with spaces, or <code>"Untitled Thread"</code> if there are none.</p>
<p>This is deliberately weak. Later in this guide, AI labeling replaces this heuristic title, along with <code>summary</code> and <code>nextStep</code>, with something grounded in what the thread is actually about (but the thread is fully usable without that step, too).</p>
<h3 id="heading-putting-it-together">Putting it Together</h3>
<p><code>buildThreads</code> ties everything in this section together:</p>
<pre><code class="language-typescript">export async function buildThreads(): Promise&lt;{ sessions: number; threads: number }&gt; {
  const sessions = await getAllSessions();

  if (sessions.length === 0) {
    await clearThreads();
    return { sessions: 0, threads: 0 };
  }

  const ambient = detectAmbientDomains(sessions);

  const builders = clusterSessions(sessions, ambient);

  const substantive = builders.filter(
    (b) =&gt; !(b.sessions.length === 1 &amp;&amp; b.sessions[0].events.length &lt; 3)
  );

  const threads = substantive.map(scoreThread);

  await clearThreads();
  await putThreads(threads);

  console.table(
    threads.map((t) =&gt; ({
      title:        t.title,
      type:         t.type,
      status:       t.status,
      confidence:   t.confidence,
      distinctDays: t.distinctDays,
      sessions:     t.sessions.length,
      events:       t.sessions.reduce((n, s) =&gt; n + s.events.length, 0),
      keywords:     [...new Set(t.sessions.flatMap((s) =&gt; s.keywords))].slice(0, 5).join(", "),
    }))
  );

  return { sessions: sessions.length, threads: threads.length };
}
</code></pre>
<p>The order here matters. <code>detectAmbientDomains</code> runs once, over every session, before any clustering happens, since ambient detection needs the full picture of your browsing to know what counts as "every day".</p>
<p><code>clusterSessions</code> then produces <code>ThreadBuilder</code>s, which get filtered before scoring: a <code>ThreadBuilder</code> with exactly one session and fewer than three events is almost always a stray page load that didn't merge with anything, so it's dropped rather than becoming a thread with a confidence near zero.</p>
<p>Everything that survives gets scored by <code>scoreThread</code>, persisted, and printed via <code>console.table</code>, which is the tuning aid mentioned earlier. If you open the service worker's console after running this, every thread is laid out in a sortable table. This is the fastest way to spot a <code>SIMILARITY_THRESHOLD</code> that's too high or too low.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>Like the previous two sections, <code>buildThreads</code> doesn't have a UI yet. It'll get wired up to a "Build intent map" button alongside the other two when you design the dashboard later in this guide.</p>
<p>For now, confirm that <code>src/pipeline/ambient.ts</code>, the updated <code>src/db/index.ts</code>, and <code>src/pipeline/threads.ts</code> all build without errors, and that <code>getDB()</code> reports version 3 the next time the extension reloads. <code>intent_threads</code> should now be listed alongside <code>raw_events</code> and <code>sessions</code> in DevTools.</p>
<p>At this point, the entire core pipeline runs end to end, locally, with no API keys involved: your browsing history becomes raw events, raw events become sessions, and sessions become scored, classified intent threads.</p>
<p>Everything from here is optional and additive: cleaning up a source of self-referential noise this pipeline doesn't yet handle (which you probably want to look at and incorporate), then AI labeling, brand grounding, and the dashboard that ties it all together.</p>
<h2 id="heading-how-to-clean-up-self-referential-noise">How to Clean Up Self-Referential Noise</h2>
<p>Run the pipeline a few times against your own browsing and a strange kind of thread starts appearing: one made entirely of openloops itself.</p>
<p>The dashboard is a web page, so every time you open it to check your threads, that page load gets captured as an event. If you're also developing the extension, your <code>localhost</code> dev server and any private-network addresses end up in the data too.</p>
<p>The tool ends up watching itself use itself, and that self-reference pollutes the intent map in two distinct ways which are worth separating.</p>
<h3 id="heading-the-two-problems">The Two Problems</h3>
<p>The first problem is the extension's own pages. A Chrome extension's dashboard loads from a <code>chrome-extension://</code> URL, and Chrome's own internal pages use <code>chrome://</code>. Left unfiltered, opening the openloops dashboard ten times in an afternoon produces ten events on a <code>chrome-extension://</code> origin, which cluster happily into a thread about, essentially, looking at your threads.</p>
<p>This is circular and useless, and because you tend to open the dashboard often while the rest of your browsing is quieter, this self-thread can score deceptively high on recency and session count.</p>
<p>The second problem is local development infrastructure. If you're building the extension, or any local project, your history fills with <code>localhost:5173</code>, <code>127.0.0.1:8080</code>, and maybe LAN addresses like <code>192.168.1.40</code>. These are real page visits as far as Chrome is concerned, but they carry no browsing intent in the sense openloops cares about. Worse, they'd later be sent to <a href="http://context.dev">context.dev</a> during brand enrichment, where they can never resolve to anything and would only waste API credits.</p>
<p>Both problems share a root cause: the pipeline is capturing URLs that aren't really part of your browsing in the first place. The fix is to define what counts as a real, external web page once, and apply that definition everywhere a URL or domain enters the system.</p>
<h3 id="heading-one-definition-applied-everywhere">One Definition, Applied Everywhere</h3>
<p>The two helpers that do this, <code>isHttpUrl</code> and <code>isLocalHost</code>, were written back when you first built <code>src/lib/util.ts</code>. We deliberately introduced them early for exactly this moment.</p>
<p><code>isHttpUrl</code> returns true only for <code>http://</code> and <code>https://</code> URLs, which excludes <code>chrome-extension://</code>, <code>chrome://</code>, <code>about:</code>, and <code>file://</code> in one stroke. <code>isLocalHost</code> returns true for <code>localhost</code>, loopback and private IP ranges, and <code>.local</code> hostnames.</p>
<p>The thing that makes them effective is consistency: the same two functions guard every entry point, so the definition of "a real page" can never drift between one part of the pipeline and another. There are three such entry points.</p>
<p>Live capture, in <code>src/background.ts</code>, calls <code>isHttpUrl</code> before recording anything:</p>
<pre><code class="language-typescript">if (!isHttpUrl(url)) return;
</code></pre>
<p>The backfill, in <code>src/pipeline/backfill.ts</code>, applies the same guard to every history item before fetching its visits:</p>
<pre><code class="language-typescript">if (!item.url) return [];
if (!isHttpUrl(item.url)) return [];
</code></pre>
<p>And the noise filter, in <code>src/pipeline/noise.ts</code>, checks both helpers at the very top of <code>isNoise</code>, before any of its domain or title rules run:</p>
<pre><code class="language-typescript">export function isNoise(event: RawEvent): boolean {
  if (!isHttpUrl(event.url)) return true;
  if (isLocalHost(event.domain)) return true;
  return domainIsBlocked(event.domain) || titleIsGeneric(event.title, event.domain);
}
</code></pre>
<p>Capture and backfill already screen out non-web URLs, so checking <code>isHttpUrl</code> a third time inside <code>isNoise</code> looks redundant, and in normal operation it is. The third check is a guarantee: if a stray non-web event ever reaches <code>raw_events</code> through some path you didn't anticipate (like a future capture mechanism, imported data, or a bug), it still can't survive into a session.</p>
<p>Each stage defends its own input rather than trusting that an earlier stage did its job. This is what keeps a single missed case from silently propagating all the way into the intent map.</p>
<h3 id="heading-defending-the-enrichment-boundary-too">Defending the Enrichment Boundary Too</h3>
<p>The same <code>isLocalHost</code> check appears once more, in the brand enrichment step you'll build next, where domains get sent to <a href="http://context.dev">context.dev</a>. Even though <code>isNoise</code> already strips local addresses before sessionization, the enrichment function filters them again before making any network call:</p>
<pre><code class="language-typescript">const unique = [...new Set(domains)].filter((d) =&gt; !isLocalHost(d));
</code></pre>
<p>The reasoning is the same defense-in-depth idea, applied to a boundary where the cost of a mistake is higher. A local address that somehow reached a thread's domain list shouldn't just be useless noise in the UI. It should never leave your machine as part of an API request. Putting the filter directly at the network boundary means that guarantee holds regardless of what happened upstream.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>After loading the updated build, openloops should stop appearing in its own intent map. To verify, open the dashboard a handful of times, browse some real pages, then rebuild the pipeline: the <code>chrome-extension://</code> self-thread should be gone, and no <code>localhost</code> or private-IP domains should appear in any thread's domain list.</p>
<p>If you inspect <code>raw_events</code> in DevTools, you may still see live-captured events from before this fix, since the backfill clears and rewrites events but live capture appends. Running a fresh "Scan my history" wipes and repopulates <code>raw_events</code> cleanly under the new rules.</p>
<p>With the pipeline now producing a clean intent map of genuinely external browsing, it's worth making those threads more legible.</p>
<p>Up to now, each thread's title is just its top three keywords stitched together, and there's no summary or suggested next step at all. The next section adds the first optional, key-gated layer: AI labeling with Claude.</p>
<h2 id="heading-how-to-label-threads-with-claude">How to Label Threads with Claude</h2>
<p>A thread titled "Typescript Generics Handbook" is readable, but it's a description of the keywords – not of what you were trying to do. "Learning TypeScript's advanced type system" is the kind of label a person would actually write, and the difference between those two is the gap this section closes.</p>
<p>Claude reads each thread's keywords, domains, and sample page titles, and returns a real title, a one-sentence summary, a classification, and a concrete next step.</p>
<p>This is the first part of openloops that calls an external API and requires a key. Everything about its design is shaped by one constraint: the request has to survive real data, where a person might have thirty or forty threads, each carrying a dozen page titles.</p>
<p>The naïve version of this is to send all the threads in one request and ask for all the labels back. And that's exactly what the first implementation did. But it failed in a way worth walking through, because the fix is the most instructive part of the whole section.</p>
<h3 id="heading-storing-keys-locally">Storing Keys Locally</h3>
<p>Before any API call, the key needs somewhere to live. openloops keeps it in <code>chrome.storage.local</code>, which never syncs anywhere and never leaves the device. Create <code>src/lib/settings.ts</code>:</p>
<pre><code class="language-typescript">export async function getApiKey(): Promise&lt;string | null&gt; {
  const result = await chrome.storage.local.get("anthropicApiKey");
  return (result.anthropicApiKey as string) ?? null;
}

export async function setApiKey(key: string): Promise&lt;void&gt; {
  await chrome.storage.local.set({ anthropicApiKey: key });
}
</code></pre>
<p>The same file later grows parallel getters and setters for the <a href="http://context.dev">context.dev</a> key and the assistant's model and effort preferences, all following this identical shape. So it's enough to understand this one pair to understand all of them.</p>
<h3 id="heading-the-first-version-and-how-it-broke">The First Version, and How it Broke</h3>
<p>The first labeling implementation sent every thread to Claude in a single request: serialize all forty threads into one JSON payload, ask for a JSON array of forty labels in return, parse it, write it back. It worked perfectly with five or six threads during early testing, then silently produced nothing once a real history with thirty-plus threads went through it. There was no error or thrown exception, just threads that kept their old keyword titles as if the labeling had never run.</p>
<p>The cause was output token truncation. A request specifies <code>max_tokens</code>, the ceiling on how much the model may generate in response, and forty threads' worth of titles, summaries, and next steps is a lot of output. When the response hit that ceiling mid-generation, the JSON array was cut off partway through an opening <code>[</code> and thirty complete objects followed by half of the thirty-first and no closing <code>]</code>. <code>JSON.parse</code> on that throws, the catch block logged it and returned nothing, and because labeling was designed to fail gracefully and leave existing titles intact, the failure was invisible from the UI.</p>
<p>Two design changes came out of this, and both are in the final code: split the work into small batches so no single response can grow large enough to truncate, and make the parsing resilient enough that one bad batch can't take down the whole run.</p>
<h3 id="heading-batching-the-requests">Batching the Requests</h3>
<p>Create <code>src/pipeline/label.ts</code>, starting with the per-batch request function:</p>
<pre><code class="language-typescript">import { getAllThreads, putThreads, getAllBrands } from "../db/index";
import type { IntentThread } from "../types";

interface ThreadDescriptor {
  id: string;
  keywords: string[];
  domains: string[];
  sampleTitles: string[];
  domainContext: string[];
}

interface LabelResult {
  id: string;
  title: string;
  summary: string;
  type: string;
  nextStep: string;
}

const VALID_TYPES: ReadonlySet&lt;IntentThread["type"]&gt; = new Set([
  "buying",
  "research",
  "learning",
  "planning",
  "unclassified",
]);

const BATCH_SIZE = 10;
const MAX_TOKENS_PER_BATCH = 4000;

async function callClaudeBatch(
  apiKey: string,
  systemPrompt: string,
  batch: ThreadDescriptor[],
): Promise&lt;LabelResult[] | null&gt; {
  const response = await fetch("https://api.anthropic.com/v1/messages", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-api-key": apiKey,
      "anthropic-version": "2023-06-01",
      "anthropic-dangerous-direct-browser-access": "true",
    },
    body: JSON.stringify({
      model: "claude-haiku-4-5-20251001",
      max_tokens: MAX_TOKENS_PER_BATCH,
      system: systemPrompt,
      messages: [
        {
          role: "user",
          content: JSON.stringify(batch),
        },
      ],
    }),
  });

  if (!response.ok) {
    let body = "";
    try { body = (await response.text()).slice(0, 400); } catch { }
    console.error(
      `[openloops] label: API request failed\n` +
      `  → HTTP \({response.status} \){response.statusText}\n` +
      `  body: ${body || "(empty)"}`,
    );
    if (response.status === 401) {
      throw new Error("Invalid API key. Check your Anthropic API key and try again.");
    }
    throw new Error(`API request failed: \({response.status} \){response.statusText}`);
  }

  const data = await response.json();
  const raw: string = data.content[0].text;

  const cleaned = raw
    .trim()
    .replace(/^```(?:json)?\s*/, "")
    .replace(/```\s*$/, "")
    .trim();

  try {
    return JSON.parse(cleaned);
  } catch (err) {
    console.error(`[openloops] label: parse error: ${err instanceof Error ? err.message : String(err)}`);
    console.error(`[openloops] label: raw tail (last 400 chars):\n${raw.slice(-400)}`);
    return null;
  }
}
</code></pre>
<p><code>BATCH_SIZE</code> of 10 with <code>MAX_TOKENS_PER_BATCH</code> of 4000 is the direct answer to the truncation problem. Ten threads' worth of labels comfortably fits inside 4000 output tokens with room to spare, so a batch can't hit the ceiling and get cut off. A history with forty threads becomes four independent requests rather than one oversized one.</p>
<p>The request itself uses raw <code>fetch</code> rather than Anthropic's TypeScript SDK, because the SDK isn't built to run in a browser or extension context.</p>
<p>Browser-originated calls to the Anthropic API also require the <code>anthropic-dangerous-direct-browser-access</code> header, which is what opts into this usage pattern. The model is Claude Haiku, the fastest and cheapest in the lineup, which is well-matched to a high-volume, structured-output task like this one where you're making several calls and want them quick.</p>
<p>The error handling splits into two deliberately different behaviors. An HTTP-level failure (a 401 from a bad key, a 429 from rate limiting) throws, because every subsequent batch would fail the same way and there's no point continuing. A <em>parse</em> failure, by contrast, returns <code>null</code> rather than throwing, so the caller can skip just that one batch and keep going with the rest.</p>
<p>The fence-stripping before <code>JSON.parse</code> handles a common real-world wrinkle: models sometimes wrap JSON output in a Markdown code fence (<code>```json</code>), even when asked for raw JSON. The two <code>.replace</code> calls strip a leading fence and a trailing fence if present, tolerating surrounding whitespace, so a response comes through whether or not it arrived wrapped.</p>
<p>When parsing still fails, the catch logs the last 400 characters of the raw response, which is precisely where you'd see the truncation signature of a cut-off array, the diagnostic that would have made the original bug obvious in minutes.</p>
<h3 id="heading-building-the-prompt-and-merging-results">Building the Prompt and Merging Results</h3>
<p>The public <code>labelThreads</code> function builds the descriptors, runs the batches, and merges what comes back:</p>
<pre><code class="language-typescript">export async function labelThreads(apiKey: string): Promise&lt;{ labeled: number }&gt; {
  const threads = await getAllThreads();
  if (threads.length === 0) return { labeled: 0 };

  const allBrands = await getAllBrands();
  const brandMap = new Map(allBrands.map((b) =&gt; [b.domain, b]));

  const descriptors: ThreadDescriptor[] = threads.map((t) =&gt; {
    const keywords = [...new Set(t.sessions.flatMap((s) =&gt; s.keywords))].slice(0, 8);
    const domains  = [...new Set(t.sessions.flatMap((s) =&gt; s.domains))].slice(0, 5);
    const titles   = [...new Set(t.sessions.flatMap((s) =&gt; s.events.map((e) =&gt; e.title)))].slice(0, 20);

    const domainContext = domains
      .map((d) =&gt; {
        const brand = brandMap.get(d);
        if (!brand || !brand.name) return null;
        let line = `\({d}: \){brand.name}`;
        if (brand.description) line += ` — ${brand.description}`;
        if (brand.industry)    line += ` (${brand.industry})`;
        return line;
      })
      .filter((s): s is string =&gt; s !== null);

    return { id: t.id, keywords, domains, sampleTitles: titles, domainContext };
  });

  const systemPrompt = `You label browsing intent threads. Return ONLY a JSON array — no markdown fences, no explanation.
Each element: { "id": "&lt;thread id&gt;", "title": "&lt;3-6 word title&gt;", "summary": "&lt;1 sentence&gt;", "type": "&lt;buying|research|learning|planning|unclassified&gt;", "nextStep": "&lt;one concrete, specific action to move this thread forward or close the loop&gt;" }
The nextStep must be grounded in what the person was actually looking at. Be specific — name the actual decision, comparison, or action (e.g. "Decide between MacBook Pro and Dell XPS — your open question was battery life") rather than generic advice ("continue researching"). Use the sampleTitles and domainContext to ground it.
Each thread descriptor may include a "domainContext" array of company descriptions for the sites visited. When present, use these to produce sharper, more specific titles, summaries, and next steps grounded in what each company actually does.
Respond with exactly one array covering every thread in the request.`;

  const allResults: LabelResult[] = [];
  let failedBatches = 0;
  for (let i = 0; i &lt; descriptors.length; i += BATCH_SIZE) {
    const batch = descriptors.slice(i, i + BATCH_SIZE);
    const results = await callClaudeBatch(apiKey, systemPrompt, batch);
    if (results === null) {
      failedBatches++;
      continue;
    }
    allResults.push(...results);
  }

  const byId = new Map(allResults.map((r) =&gt; [r.id, r]));

  let labeled = 0;
  const updated = threads.map((t) =&gt; {
    const label = byId.get(t.id);
    if (!label) return t;

    const type = VALID_TYPES.has(label.type as IntentThread["type"])
      ? (label.type as IntentThread["type"])
      : t.type;

    labeled++;
    return {
      ...t,
      title:    label.title    || t.title,
      summary:  label.summary  || undefined,
      nextStep: label.nextStep || undefined,
      type,
    };
  });

  await putThreads(updated);
  return { labeled };
}
</code></pre>
<p>Each thread is compressed into a <code>ThreadDescriptor</code> carrying only what Claude needs to label it: up to eight keywords, five domains, and twenty sample page titles, capped so a thread with hundreds of events doesn't bloat the payload.</p>
<p>The <code>domainContext</code> field is the hook for the brand-grounding step covered in the next section. It's empty for now since no brands have been fetched yet, which is exactly why labeling works fine on its own and gets sharper once grounding is added.</p>
<p>The merge step is where a failed batch costs you only its own threads. Results come back as a flat list across all successful batches, indexed by thread id into <code>byId</code>.</p>
<p>Then every thread is walked: if a label came back for it, the AI title, summary, next step, and type are merged in, with the returned <code>type</code> validated against <code>VALID_TYPES</code> and falling back to the heuristic type if the model returned something unexpected. If no label came back, because that thread's batch failed to parse, the thread is returned untouched, keeping the keyword title and heuristic classification it already had.</p>
<p>A single failed batch costs you ten threads' worth of polish, not the entire run, and never corrupts a thread with malformed data.</p>
<p>Notice that <code>title</code>, <code>summary</code>, and <code>nextStep</code> all guard against empty strings with <code>|| t.title</code> and <code>|| undefined</code>. A thread always has a usable title even if the model returned a blank one, and <code>summary</code> and <code>nextStep</code> stay <code>undefined</code> rather than becoming empty strings. This keeps the dashboard's "does this thread have a summary?" checks honest.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>Labeling needs a key and a button, both of which arrive with the dashboard later in this guide, so a full end-to-end test waits until then.</p>
<p>What you can verify now is that <code>src/lib/settings.ts</code> and <code>src/pipeline/label.ts</code> compile, and that the request shape is correct by calling <code>labelThreads</code> with a real key from a temporary test harness if you want immediate feedback. When it runs against built threads, the <code>console</code> will show batch progress, and your threads' titles in IndexedDB will change from keyword fragments to readable phrases, with <code>summary</code> and <code>nextStep</code> fields appearing for the first time.</p>
<p>The labels are already a large improvement, but they're working from keywords and bare domain names. This means a thread built around <code>mastra.ai</code> and <code>langchain.com</code> has no idea those are AI agent frameworks. It only sees two domain strings.</p>
<p>The next section closes that gap by resolving domains into real company descriptions before labeling. This is the grounding step that gives the AI something concrete to reason about.</p>
<h2 id="heading-how-to-ground-labels-with-contextdev">How to Ground Labels with <a href="http://context.dev">context.dev</a></h2>
<p>This is the most distinctive idea in openloops, so it's worth stating plainly before any code: instead of asking the model to label a thread from keywords and bare domain names, openloops first resolves each domain into a real company description – what the company is, what industry it's in, what it actually does – and feeds those descriptions into the labeling prompt. The model labels the thread knowing that <code>mastra.ai</code> and <code>langchain.com</code> are both AI agent frameworks, rather than seeing two opaque strings it has to guess about.</p>
<p>A thread whose keywords are "mastra langchain sholajegede" produces, ungrounded, a title like "Mastra Langchain Sholajegede", a literal echo of the keywords. Grounded with the knowledge that those domains are competing agent frameworks, the same thread becomes "Benchmarking Mastra against LangChain", a title that names the actual intent.</p>
<p>The raw material for a good label was always there in the browsing. What was missing was the context to interpret it, and that context is exactly what a brand-intelligence API provides.</p>
<h3 id="heading-what-the-api-returns">What the API Returns</h3>
<p>openloops uses context.dev, which resolves a domain into a structured brand record: company name, a one-line description, industry classification, brand colors, and logo URLs. The grounding step needs the name, description, and industry, while the logo and colors get used later by the dashboard to render domain chips.</p>
<p>This step is entirely optional: the labeling from the previous section works without it, and grounding simply makes the output sharper when a context.dev key is present.</p>
<p>Like the Anthropic key, the context.dev key lives in <code>chrome.storage.local</code>, via the same getter/setter pattern in <code>src/lib/settings.ts</code>:</p>
<pre><code class="language-typescript">export async function getContextKey(): Promise&lt;string | null&gt; {
  const result = await chrome.storage.local.get("contextDevApiKey");
  return (result.contextDevApiKey as string) ?? null;
}

export async function setContextKey(key: string): Promise&lt;void&gt; {
  await chrome.storage.local.set({ contextDevApiKey: key });
}
</code></pre>
<p>Brand records also need a place to be cached, since resolving the same domain twice is wasteful and costs API credits. Bump <code>DB_VERSION</code> to 4 and add a <code>domain_brands</code> store keyed by domain:</p>
<pre><code class="language-typescript">import type { RawEvent, Session, IntentThread, Brand } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: { key: string; value: RawEvent; indexes: { by_visitedAt: number } };
  sessions: { key: string; value: Session; indexes: { by_startedAt: number } };
  intent_threads: { key: string; value: IntentThread; indexes: { by_lastSeen: number } };
  domain_brands: {
    key: string;
    value: Brand;
  };
}

const DB_VERSION = 4;
</code></pre>
<p>Inside the <code>upgrade</code> callback, the new store is added with the same guard as the others, and <code>domain_brands</code> is keyed on <code>domain</code> rather than <code>id</code> because a domain is its own natural unique key:</p>
<pre><code class="language-typescript">if (!db.objectStoreNames.contains("domain_brands")) {
  db.createObjectStore("domain_brands", { keyPath: "domain" });
}
</code></pre>
<p>The matching helpers add one that's specific to caching, <code>getCachedDomains</code>. This returns the set of domains already resolved so the enrichment step can skip them:</p>
<pre><code class="language-typescript">export async function getBrand(domain: string): Promise&lt;Brand | undefined&gt; {
  const db = await getDB();
  return db.get("domain_brands", domain);
}

export async function putBrands(brands: Brand[]): Promise&lt;void&gt; {
  if (brands.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("domain_brands", "readwrite");
  await Promise.all([...brands.map((b) =&gt; tx.store.put(b)), tx.done]);
}

export async function getAllBrands(): Promise&lt;Brand[]&gt; {
  const db = await getDB();
  return db.getAll("domain_brands");
}

export async function getCachedDomains(): Promise&lt;Set&lt;string&gt;&gt; {
  const db = await getDB();
  const keys = await db.getAllKeys("domain_brands");
  return new Set(keys);
}
</code></pre>
<h3 id="heading-fetching-one-brand">Fetching One Brand</h3>
<p>Create <code>src/pipeline/enrich.ts</code>. The core is a function that resolves a single domain, and most of its length is there to make sure a slow or failing lookup can never hang or crash the whole step:</p>
<pre><code class="language-typescript">import { getCachedDomains, putBrands } from "../db/index";
import { isLocalHost } from "../lib/util";
import type { Brand } from "../types";

const API_BASE        = "https://api.context.dev/v1";
const LOGO_LINK_BASE  = "https://logos.context.dev";

const REQUEST_TIMEOUT_MS = 15_000;
const BATCH_SIZE     = 3;
const BATCH_DELAY_MS = 2_000;

interface FetchResult {
  brand: Brand | null;
  errorCode?: string;
}

async function fetchBrand(domain: string, contextKey: string): Promise&lt;FetchResult&gt; {
  const url = `\({API_BASE}/brand/retrieve?domain=\){encodeURIComponent(domain)}`;
  const headers = { Authorization: `Bearer ${contextKey}` };

  async function attempt(): Promise&lt;Response&gt; {
    const ctrl = new AbortController();
    const tid  = setTimeout(() =&gt; ctrl.abort(), REQUEST_TIMEOUT_MS);
    try {
      return await fetch(url, { headers, signal: ctrl.signal });
    } finally {
      clearTimeout(tid);
    }
  }

  try {
    let res = await attempt();

    if (res.status === 408) {
      res = await attempt();
    }

    if (!res.ok) {
      let body = "";
      try { body = (await res.text()).slice(0, 400); } catch { }
      console.error(`[openloops] enrich: HTTP \({res.status} for "\){domain}" — ${body}`);
      return { brand: null, errorCode: String(res.status) };
    }

    let data: { status?: string; brand?: Record&lt;string, unknown&gt; };
    try {
      data = await res.json();
    } catch (e) {
      return { brand: null, errorCode: "parse" };
    }

    if (data.status !== "ok" || !data.brand) {
      return { brand: null, errorCode: "shape" };
    }

    const b = data.brand as {
      title?:        string;
      description?:  string;
      colors?:       { hex?: string }[];
      logos?:        { url?: string }[];
      industries?:   { eic?: { industry?: string; subindustry?: string }[] };
    };

    const logoUrl =
      b.logos?.[0]?.url ||
      `\({LOGO_LINK_BASE}?domain=\){encodeURIComponent(domain)}`;

    return {
      brand: {
        domain,
        name:        b.title                          ?? domain,
        description: b.description                    ?? "",
        industry:    b.industries?.eic?.[0]?.industry ?? "",
        logoUrl,
        brandColor:  b.colors?.[0]?.hex               ?? "",
      },
    };

  } catch (err) {
    if (err instanceof Error &amp;&amp; err.name === "AbortError") {
      return { brand: null, errorCode: "timeout" };
    }
    return { brand: null, errorCode: "network" };
  }
}
</code></pre>
<p>The request authenticates with a bearer token and hits a single <code>brand/retrieve</code> endpoint. The <code>attempt</code> inner function wraps each call in an <code>AbortController</code> with a 15-second timeout, so a stalled connection aborts itself rather than hanging the enrichment step indefinitely.</p>
<p>The <code>finally</code> clears the timer whether the request succeeds, fails, or aborts. A <code>408</code> response from context.dev means a cold cache miss on their side, which their documentation says to retry once, so a single retry handles it before giving up.</p>
<p>The response is unpacked defensively at every level: a non-OK status returns a <code>FetchResult</code> with the HTTP code, a body that won't parse returns a <code>"parse"</code> error, and a response whose shape isn't what's expected returns a <code>"shape"</code> error.</p>
<p>When the brand record does come through, each field falls back to a sensible default if absent, the company name falls back to the domain itself, the description and industry to empty strings, and the logo to context.dev's keyless logo CDN if the record carries no logo URL.</p>
<p>Every failure path returns <code>{ brand: null, errorCode }</code> rather than throwing, which is what lets the batch driver above it treat a single domain's failure as a skip rather than a crash.</p>
<h3 id="heading-enriching-domains-in-batches">Enriching Domains in Batches</h3>
<p>The public <code>enrichDomains</code> function resolves a list of domains, skipping ones already cached and respecting the API's rate limit:</p>
<pre><code class="language-typescript">export async function enrichDomains(
  contextKey: string,
  domains: string[],
): Promise&lt;{ enriched: number; failed: number; error?: string }&gt; {
  const unique = [...new Set(domains)].filter((d) =&gt; !isLocalHost(d));

  let cached: Set&lt;string&gt;;
  try {
    cached = await getCachedDomains();
  } catch (err) {
    return { enriched: 0, failed: 0, error: "DB error" };
  }

  const toFetch = unique.filter((d) =&gt; !cached.has(d));
  if (toFetch.length === 0) return { enriched: 0, failed: 0 };

  let enriched = 0;
  let failed   = 0;
  let firstErrorCode: string | undefined;

  for (let i = 0; i &lt; toFetch.length; i += BATCH_SIZE) {
    const batch   = toFetch.slice(i, i + BATCH_SIZE);
    const results = await Promise.all(batch.map((d) =&gt; fetchBrand(d, contextKey)));

    const brands = results.map((r) =&gt; r.brand).filter((b): b is Brand =&gt; b !== null);

    for (const r of results) {
      if (!r.brand) {
        failed += 1;
        if (!firstErrorCode) firstErrorCode = r.errorCode;
      }
    }

    if (brands.length &gt; 0) {
      try {
        await putBrands(brands);
        enriched += brands.length;
      } catch (err) {
        failed += brands.length;
      }
    }

    if (i + BATCH_SIZE &lt; toFetch.length) {
      await new Promise&lt;void&gt;((resolve) =&gt; setTimeout(resolve, BATCH_DELAY_MS));
    }
  }

  let error: string | undefined;
  if (firstErrorCode) {
    const map: Record&lt;string, string&gt; = {
      "401":     "401 — invalid key",
      "403":     "403 — check key permissions",
      "429":     "429 — rate limited, try again later",
      "timeout": "request timeout (15 s)",
      "network": "unreachable — check network/CORS",
    };
    error = map[firstErrorCode] ?? firstErrorCode;
  }

  return { enriched, failed, error };
}
</code></pre>
<p>The function opens by stripping local addresses with <code>isLocalHost</code>, the enrichment-boundary guard discussed in the self-referential noise section. This means that a dev server can never be sent to context.dev even if it slipped into a thread's domain list. It then removes already-cached domains via <code>getCachedDomains</code>, so re-running enrichment only ever fetches domains it hasn't seen. This keeps credit usage proportional to new browsing rather than total browsing.</p>
<p>The remaining domains are fetched three at a time, with a two-second pause between batches. This keeps the request rate well under the API's limit without making the user wait through a long serial queue.</p>
<p>Failures are tallied rather than thrown: a domain that fails to resolve increments <code>failed</code> and records its error code, but the loop carries on. The first error code encountered gets mapped to a human-readable message at the end so the UI can show something useful, such as an invalid-key or rate-limit notice.</p>
<p>The whole function returns counts rather than raising, which matters because the dashboard runs enrichment immediately before labeling, and a problem fetching brands should never prevent the labeling that follows it.</p>
<h3 id="heading-how-grounding-feeds-back-into-labeling">How Grounding Feeds Back into Labeling</h3>
<p>Grounding connects back to <code>labelThreads</code> from the previous section, which already builds a <code>domainContext</code> array for each thread by looking up every domain in the brand cache:</p>
<pre><code class="language-typescript">const domainContext = domains
  .map((d) =&gt; {
    const brand = brandMap.get(d);
    if (!brand || !brand.name) return null;
    let line = `\({d}: \){brand.name}`;
    if (brand.description) line += ` — ${brand.description}`;
    if (brand.industry)    line += ` (${brand.industry})`;
    return line;
  })
  .filter((s): s is string =&gt; s !== null);
</code></pre>
<p>Before enrichment runs, the brand cache is empty, every lookup returns nothing, <code>domainContext</code> is an empty array, and the prompt falls back to keywords and domain names alone.</p>
<p>After enrichment, the same code produces lines like <code>mastra.ai: Mastra — TypeScript framework for building AI agents (Developer Tools)</code>, and the labeling prompt's instruction to use <code>domainContext</code> "to produce sharper, more specific titles, summaries, and next steps" finally has something to work with.</p>
<p>The two steps are decoupled by design: labeling never requires grounding, but grounding measurably improves labeling. This is why the dashboard runs them in sequence as a single "enrich, then label" action.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>Like the labeling step, enrichment is exercised through the dashboard, so the full path waits for the dashboard section. For now, confirm that <code>src/pipeline/enrich.ts</code> and the updated <code>src/db/index.ts</code> compile, and that <code>getDB()</code> reports version 4 with <code>domain_brands</code> present in DevTools.</p>
<p>Once it runs against real threads with a context.dev key, the <code>domain_brands</code> store fills with cached records, and your thread labels should noticeably sharpen. The clearest single demonstration will be any thread built around niche or technical domains whose names don't, on their own, reveal what they are.</p>
<p>Every piece of the engine now exists: capture, sessions, clustering, scoring, labeling, and grounding. What's missing is the surface that drives them and shows the results.</p>
<p>The next section builds the dashboard, the three-column React interface with its onboarding flow and pipeline state machine, that turns this pipeline into something a person actually uses.</p>
<h2 id="heading-how-to-design-the-dashboard">How to Design the Dashboard</h2>
<p>The dashboard is a single React component tree rendered into the full-tab page you wired up at the very start when you set <code>options_page</code> in the manifest.</p>
<p>It does three jobs: it drives the pipeline (the buttons that run scanning, session-building, thread-building, and labeling), it displays the resulting intent map (threads grouped by status), and it hosts the assistant covered in the next section.</p>
<p>This section focuses on the structure and the one piece of genuinely interesting logic: the state machine that decides which pipeline button is live at any moment. We'll treat the styling at a summary level here, since it's mostly conventional CSS.</p>
<h3 id="heading-the-three-column-layout">The Three-Column Layout</h3>
<p><code>src/dashboard/App.tsx</code> lays out three columns inside a flex shell. The left rail holds the pipeline controls, the API-key inputs, and the status filter. The center column is the main content: either the onboarding welcome screen or the intent map of threads. The right column holds overview statistics and the assistant chat.</p>
<pre><code class="language-plaintext">┌──────────────┬───────────────────────────┬──────────────────┐
│  LEFT RAIL   │       MAIN COLUMN         │  RIGHT COLUMN    │
│              │                           │                  │
│  Pipeline    │  Welcome screen           │  Overview stats  │
│   · Scan     │    — or —                 │                  │
│   · Sessions │  Intent map:              │  Assistant chat  │
│   · Threads  │   ACTIVE   threads        │   · messages     │
│              │   STALLED  threads        │   · composer     │
│  Keys        │   DORMANT  threads        │   · model/effort │
│  Filter      │                           │                  │
└──────────────┴───────────────────────────┴──────────────────┘
</code></pre>
<p>Each thread renders as a card showing its title, type and status pills, the AI summary, the next-step row with a Resume button, a confidence bar, and a collapsible details section with domains, keywords, and signals.</p>
<p>The cards are grouped into ACTIVE, STALLED, and DORMANT sections, sorted by confidence within each group. The threads most worth acting on rise to the top of the most urgent group.</p>
<p>The styling lives in <code>src/dashboard/app.css</code> and is conventional: a dark theme defined through CSS custom properties (a near-black background, a single orange accent at <code>--accent: #ff5c33</code>, a small scale of grays for text and borders), a monospace font for labels and metadata, and a sans-serif for content.</p>
<p>The design choices that matter for usability are the status-based color coding (the accent for active, a muted amber for stalled, gray for dormant) and the confidence bar's width mapping directly to the thread's confidence score.</p>
<p>None of the CSS is load-bearing for understanding the build, so rather than reproduce it, the rest of this section focuses on the logic the styling sits on top of.</p>
<h3 id="heading-the-pipeline-state-machine">The Pipeline State Machine</h3>
<p>The pipeline has a strict order: you can't build sessions before scanning history, and you can't build threads before building sessions. The dashboard encodes this as a small state machine, and getting it right is what makes the interface feel guided rather than confusing. Every button is either disabled (its input doesn't exist yet), highlighted as the next action to take, or done (re-runnable, but no longer the obvious next step).</p>
<pre><code class="language-typescript">type PipelineState = "disabled" | "next" | "done";

function pipelineStates(
  hasScanned: boolean,
  eventCount: number | null,
  sessionCount: number | null,
  threadCount: number | null,
): { scan: PipelineState; sessions: PipelineState; threads: PipelineState } {
  const hasEvents   = (eventCount   ?? 0) &gt; 0;
  const hasSessions = (sessionCount ?? 0) &gt; 0;
  const hasThreads  = (threadCount  ?? 0) &gt; 0;

  if (!hasScanned)  return { scan: "next", sessions: "disabled", threads: "disabled" };
  if (!hasSessions) return { scan: "done", sessions: hasEvents ? "next" : "disabled", threads: "disabled" };
  if (!hasThreads)  return { scan: "done", sessions: "done", threads: "next" };
  return { scan: "done", sessions: "done", threads: "done" };
}
</code></pre>
<p>The function reads the presence of data at each stage and returns the state of all three buttons. Before any scan, only Scan is live, marked <code>next</code>, while the other two are disabled.</p>
<p>Once events exist but sessions don't, Scan flips to <code>done</code> and Sessions becomes <code>next</code>. Once sessions exist but threads don't, Threads becomes <code>next</code>. Once all three stages have produced output, everything is <code>done</code>, every step re-runnable but none demanding attention. The cascade walks the pipeline in order and lights up exactly one <code>next</code> action at a time, which is what turns a row of three buttons into a guided sequence.</p>
<p>The first parameter, <code>hasScanned</code>, is more subtle than a simple count. It's where a piece of plumbing from the very first capture section pays off.</p>
<p>The check can't just be "are there any events," because live capture starts populating <code>raw_events</code> the moment the extension is installed. There would <em>always</em> be events, and the onboarding would skip straight past the Scan step before the user had ever scanned.</p>
<p>The fix is the <code>source</code> field on every <code>RawEvent</code>, set to <code>"backfill"</code> or <code>"live"</code> back when you built capture. <code>hasScanned</code> comes from a dedicated query that checks specifically for backfill events:</p>
<pre><code class="language-typescript">export async function hasBackfillEvents(): Promise&lt;boolean&gt; {
  const db = await getDB();
  let cursor = await db.transaction("raw_events", "readonly").store.openCursor();
  while (cursor) {
    if (cursor.value.source === "backfill") return true;
    cursor = await cursor.continue();
  }
  return false;
}
</code></pre>
<p>This walks <code>raw_events</code> until it finds a single event with <code>source === "backfill"</code>, returning early the moment it does. Live-captured events alone never satisfy it, so "Scan my history" stays lit as the first step until the user actually runs a backfill, which is the correct onboarding behavior. The seemingly minor decision to tag each event with its origin, made several sections ago, is what makes this distinction possible now.</p>
<h3 id="heading-driving-the-welcome-screen-from-the-same-machine">Driving the Welcome Screen from the Same Machine</h3>
<p>A first-time user with no threads sees a centered welcome screen instead of an empty intent map. But rather than give that screen its own separate logic, the dashboard drives it from the same <code>pipelineStates</code> output. Whichever step is currently <code>next</code> determines which single call-to-action the welcome screen shows:</p>
<pre><code class="language-typescript">let welcomeStep: 1 | 2 | 3 = 1;
let welcomeCtaLabel = "Scan my history";
let welcomeCtaClick = handleScan;
if (scanState === "next") {
  welcomeStep = 1;
  welcomeCtaLabel = scanning ? "Scanning…" : "Scan my history";
  welcomeCtaClick = handleScan;
} else if (sessionsState === "next") {
  welcomeStep = 2;
  welcomeCtaLabel = buildingSessions ? "Building…" : "Build sessions";
  welcomeCtaClick = handleBuildSessions;
} else if (threadsState === "next") {
  welcomeStep = 3;
  welcomeCtaLabel = buildingThreads ? "Building…" : "Build your intent map";
  welcomeCtaClick = handleBuildThreads;
}
</code></pre>
<p>The welcome screen's single button always mirrors the rail's <code>next</code> action, so a user can move through scan, build sessions, and build threads by clicking one prominent button three times. The moment threads exist, the welcome screen is replaced by the intent map. The rail and the welcome screen never disagree about what to do next, because both read from the same source of truth.</p>
<h3 id="heading-wiring-the-handlers">Wiring the Handlers</h3>
<p>The handlers themselves are thin: each runs a pipeline stage, then refreshes the component's view of the database. The action that runs grounding and labeling together is the one worth seeing, because it puts into practice the decoupling described in the previous two sections:</p>
<pre><code class="language-typescript">async function handleEnrichAndLabel() {
  setLabelError(null);
  setEnrichError(null);

  if (contextKey.trim() &amp;&amp; contextKeySaved) {
    setEnriching(true);
    try {
      const allDomains = [...new Set(
        threads.flatMap((t) =&gt; t.sessions.flatMap((s) =&gt; s.domains))
      )];
      const result = await enrichDomains(contextKey.trim(), allDomains);
      if (result.error) setEnrichError(`context.dev: ${result.error}`);
      if (result.enriched &gt; 0) {
        const all = await getAllBrands();
        setBrands(new Map(all.map((b) =&gt; [b.domain, b])));
      }
    } catch (err) {
      setEnrichError(`context.dev: ${err instanceof Error ? err.message : "unknown error"}`);
    } finally {
      setEnriching(false);
    }
  }

  setLabeling(true);
  try {
    await labelThreads(apiKey.trim());
    setThreads(await getAllThreads());
  } catch (err) {
    setLabelError(err instanceof Error ? err.message : "Labeling failed.");
  } finally {
    setLabeling(false);
  }
}
</code></pre>
<p>Enrichment runs only if a context.dev key is present, and it's wrapped so that any failure (like a network error, a bad key, or a rate limit) sets an error message but never stops execution. Labeling then runs unconditionally afterward, outside the enrichment block, so it proceeds whether enrichment succeeded, failed, or was skipped entirely for lack of a key.</p>
<p>That structure is the decoupling from the grounding section made concrete: grounding improves labeling when it works, and labeling degrades gracefully to keyword-and-domain context when it doesn't.</p>
<p>The enrichment error surfaces in amber rather than red, because it's a warning (labeling still happened) rather than a blocking failure. This is a small UI cue that matches the actual severity of what went wrong.</p>
<h3 id="heading-the-resume-button">The Resume Button</h3>
<p>One interaction ties the intent map back to live browsing. Each thread card has a Resume button that reopens the pages you were on, so acting on a thread is one click rather than a hunt through history:</p>
<pre><code class="language-typescript">const RESUME_SKIP_DOMAINS = new Set([
  "google.com", "youtube.com", "bing.com", "duckduckgo.com",
  "gmail.com", "mail.google.com",
]);

function resumeThread(thread: IntentThread): void {
  const seen = new Set&lt;string&gt;();
  const urls: string[] = [];

  const sorted = thread.sessions
    .flatMap((s) =&gt; s.events)
    .sort((a, b) =&gt; b.visitedAt - a.visitedAt);

  for (const ev of sorted) {
    if (RESUME_SKIP_DOMAINS.has(ev.domain)) continue;
    if (seen.has(ev.url)) continue;
    seen.add(ev.url);
    urls.push(ev.url);
    if (urls.length &gt;= 3) break;
  }

  urls.forEach((url, i) =&gt; {
    chrome.tabs.create({ url, active: i === 0 });
  });
}
</code></pre>
<p>Resume sorts the thread's events newest-first, skips search engines and webmail (which are waypoints rather than destinations you'd want to return to), dedupes by URL, and opens the three most recent meaningful pages. The first is the active tab and the rest are in the background. It's a small feature, but it's the thing that makes a thread feel like a place you can return to rather than a record of where you've been.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>With the dashboard wired up, the entire pipeline is finally usable end to end through the interface. Reload the extension, open the dashboard, and you should see the welcome screen prompting you to scan.</p>
<p>Click through scan, build sessions, build your intent map, and the threads should appear, grouped by status. Add an Anthropic key, optionally a context.dev key, and click "Label &amp; enrich" to see titles and next steps sharpen. The full loop you've built across every previous section now runs from a single screen.</p>
<p>What remains is the conversational layer on the right: an AI assistant that can reason across all your threads at once and answer questions like "what should I close this week?" The next section builds it.</p>
<h2 id="heading-how-to-build-the-ai-assistant">How to Build the AI Assistant</h2>
<p>The labeling step asks Claude to describe one thread at a time. The assistant asks something harder: to reason across all of your threads together and answer open-ended questions about them, like what to close this week, what you've stalled on longest, or how to finish a particular one.</p>
<p>This is a chat interface, but a constrained one – grounded entirely in your own thread data, so its answers reference real threads by name rather than offering generic productivity advice.</p>
<p>The whole design rests on one idea: a chat assistant is only as good as the context it's given. So most of the work here is in building the right grounding context for each message, not in the chat mechanics themselves.</p>
<h3 id="heading-grounding-the-conversation">Grounding the Conversation</h3>
<p>Before any message goes to Claude, the assistant assembles a system prompt describing the user's threads. It does this in one of two modes, depending on whether the user has clicked into a specific thread.</p>
<p>With no thread selected, it builds a compact digest of every thread. With one selected, it gives rich detail on that thread and a brief list of the others.</p>
<pre><code class="language-typescript">function buildGroundingContext(
  threads: IntentThread[],
  brands: Map&lt;string, Brand&gt;,
  selectedThread: IntentThread | null,
): string {
  if (!selectedThread) {
    const digest = threads
      .map((t) =&gt; {
        const domains = [...new Set(t.sessions.flatMap((s) =&gt; s.domains))].slice(0, 5).join(", ");
        return `- \({t.title} (\){t.status}, \({t.type}): \){t.summary ?? "no summary yet"} | next: \({t.nextStep ?? "none"} | domains: \){domains || "none"}`;
      })
      .join("\n");

    return `\({SYSTEM_INSTRUCTION}\n\nHere is a digest of all the user's open intent threads:\n\){digest || "(no threads yet)"}`;
  }

  const keywords = [...new Set(selectedThread.sessions.flatMap((s) =&gt; s.keywords))].slice(0, 10).join(", ");
  const domains = [...new Set(selectedThread.sessions.flatMap((s) =&gt; s.domains))].slice(0, 5);

  const domainLines = domains
    .map((d) =&gt; {
      const brand = brands.get(d);
      if (brand?.description) return `- \({d}: \){brand.name} — ${brand.description}`;
      return `- ${d}`;
    })
    .join("\n");

  const sampleTitles = [...new Set(selectedThread.sessions.flatMap((s) =&gt; s.events.map((e) =&gt; e.title)))]
    .slice(0, 20)
    .map((t) =&gt; `- ${t}`)
    .join("\n");

  const otherTitles = threads
    .filter((t) =&gt; t.id !== selectedThread.id)
    .map((t) =&gt; t.title)
    .join(", ");

  return `${SYSTEM_INSTRUCTION}

The user is focused on this thread:
Title: ${selectedThread.title}
Status: ${selectedThread.status}
Type: ${selectedThread.type}
Summary: ${selectedThread.summary ?? "none"}
Next step: ${selectedThread.nextStep ?? "none"}
Keywords: ${keywords || "none"}

Domains visited:
${domainLines || "(none)"}

Recent page titles:
${sampleTitles || "(none)"}

For context, the user's other open threads are: ${otherTitles || "none"}.`;
}
</code></pre>
<p>The two modes match the two kinds of questions people ask. A question like "what should I close this week?" is about the whole set, so the digest mode gives Claude a one-line summary of every thread. This is enough breadth to compare and prioritize across all of them.</p>
<p>A question like "how do I finish this one?", on the other hand, is about a single thread, so the focused mode trades breadth for depth. It hands over that thread's keywords, its domains with their brand descriptions, and up to twenty real page titles, while still naming the other threads so Claude knows what else is in play.</p>
<p>The focused mode is where brand grounding shows up again. The same brand records fetched during enrichment get woven into the domain list, so when the user asks about a thread, Claude sees <code>mastra.ai: Mastra — TypeScript framework for building AI agents</code> rather than a bare domain. This is the identical grounding principle from labeling, now applied to conversation.</p>
<p>The system instruction that prefixes both modes pins the assistant to its data:</p>
<pre><code class="language-typescript">const SYSTEM_INSTRUCTION =
  `You are the assistant inside "openloops", a browser extension that reconstructs ` +
  `the user's browsing history into "intent threads" — decisions, research, or ` +
  `plans they started and haven't closed. Help the user understand and act on ` +
  `these open loops. Be concrete: reference the actual threads by name and ` +
  `suggest real next actions. You are grounded only in the thread data provided ` +
  `below — if the user asks about something not present in it, say so plainly ` +
  `rather than guessing.`;
</code></pre>
<p>The final instruction is the important one: telling the model to admit when something isn't in its data, rather than inventing a plausible answer, is what keeps the assistant trustworthy when a user asks about a thread that doesn't exist or a detail the data doesn't contain.</p>
<h3 id="heading-sending-a-message">Sending a Message</h3>
<p>The send function rebuilds the grounding context fresh on every message. The assistant always reflects the current state of the threads (including any that changed since the conversation started) and posts the whole message history to Claude:</p>
<pre><code class="language-typescript">async function send(text: string) {
  const trimmed = text.trim();
  if (!trimmed || sending) return;

  if (!keySaved) {
    setError("Add your Anthropic key above to chat.");
    return;
  }

  setError(null);
  const nextMessages: Message[] = [...messages, { role: "user", content: trimmed }];
  setMessages(nextMessages);
  setInput("");
  setSending(true);

  try {
    const systemPrompt = buildGroundingContext(threads, brands, selectedThread);
    const maxTokens = EFFORT_OPTIONS.find((e) =&gt; e.id === effort)?.maxTokens ?? 1024;

    const response = await fetch("https://api.anthropic.com/v1/messages", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-api-key": apiKey,
        "anthropic-version": "2023-06-01",
        "anthropic-dangerous-direct-browser-access": "true",
      },
      body: JSON.stringify({
        model,
        max_tokens: maxTokens,
        system: systemPrompt,
        messages: nextMessages.map((m) =&gt; ({ role: m.role, content: m.content })),
      }),
    });

    if (!response.ok) {
      if (response.status === 401) {
        throw new Error("Invalid API key. Check your Anthropic API key and try again.");
      }
      throw new Error(`API request failed: \({response.status} \){response.statusText}`);
    }

    const data: { content: AnthropicContentBlock[] } = await response.json();
    const reply = data.content
      .filter((b) =&gt; b.type === "text" &amp;&amp; b.text)
      .map((b) =&gt; b.text)
      .join("");

    setMessages((prev) =&gt; [...prev, { role: "assistant", content: reply || "(empty response)" }]);
  } catch (err) {
    setError(err instanceof Error ? err.message : "Something went wrong.");
  } finally {
    setSending(false);
  }
}
</code></pre>
<p>The mechanics mirror the labeling request, the same endpoint, the same browser-access header, and the same 401-aware error handling, since both talk to the same API from the same constrained environment. The user's message gets appended to the running <code>messages</code> array, the full array is sent so the model has the conversation so far, and the assembled grounding context rides along as the <code>system</code> prompt. The reply is extracted by concatenating the text blocks from the response, with a fallback string if the model returned nothing usable.</p>
<p>Rebuilding <code>buildGroundingContext</code> on every send rather than once per conversation is a deliberate choice: if the user re-runs the pipeline or labels their threads mid-conversation, the next message reflects the updated data automatically, with no stale snapshot from when the chat began.</p>
<h3 id="heading-model-and-effort-controls">Model and Effort Controls</h3>
<p>The assistant exposes two selectors: which model to use and how much depth to allow. Both are persisted to <code>chrome.storage.local</code> through the same settings pattern as the keys:</p>
<pre><code class="language-typescript">const MODEL_OPTIONS = [
  { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5 — fastest" },
  { id: "claude-sonnet-4-6",          label: "Sonnet 4.6 — balanced" },
  { id: "claude-opus-4-8",            label: "Opus 4.8 — most capable" },
];

const EFFORT_OPTIONS = [
  { id: "low",    label: "Low",    maxTokens: 512 },
  { id: "medium", label: "Medium", maxTokens: 1024 },
  { id: "high",   label: "High",   maxTokens: 2048 },
];
</code></pre>
<p>The model selector spans the speed-versus-capability range: Haiku for quick answers, Opus for harder reasoning over a tangled set of threads. The effort selector maps to <code>max_tokens</code>, controlling how long an answer the model may produce. This is a reasonable proxy for response depth given the Messages API has no dedicated depth control. A user wanting a one-line answer picks Low, while one wanting a reasoned, prioritized plan picks High.</p>
<h3 id="heading-rendering-replies-and-the-empty-state">Rendering Replies and the Empty State</h3>
<p>The assistant renders Claude's replies as Markdown, since the model naturally formats prioritized lists and step-by-step suggestions with headings and bullets. This would look like raw asterisks and hashes if rendered as plain text. Using <code>react-markdown</code>, the reply component is essentially <code>&lt;ReactMarkdown&gt;{m.content}&lt;/ReactMarkdown&gt;</code> for assistant messages, with user messages rendered as plain text. The accompanying styles target the rendered Markdown elements to match the dashboard's type scale.</p>
<p>Before any conversation starts, the panel shows an empty state with a one-line explanation and a few suggested prompts as clickable chips, "What should I close this week?", "Summarize my open loops", "What have I stalled on longest?". These both demonstrate what the assistant can do and give a one-click way to start.</p>
<p>The suggested prompts shift slightly when a thread is focused, offering "How do I finish this one?" in place of the whole-set summary, matching the focused grounding mode.</p>
<p>A privacy line sits permanently below the composer, stating that chats send thread titles and summaries to Anthropic and nothing else leaves the device. This is the same honest disclosure principle applied throughout, placed where the user will see it before they type.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>With the assistant in place, openloops is feature-complete. Reload, build your intent map, add your Anthropic key, and try the suggested prompts. Ask what to close this week and the assistant should name specific threads and reason about which are easy wins versus which need a real decision. Click into a single thread and ask how to finish it, and the answer should narrow to that thread's specifics.</p>
<p>The conversation reflects your real, current threads, and nothing about it leaves your machine except the thread summaries you can see in the grounding context itself.</p>
<p>The build is done. The final section steps back to look at what you've made: how it compares to the one mainstream attempt at this idea, what the privacy model adds up to, and where you might take it next.</p>
<h2 id="heading-what-youve-built-and-where-to-take-it">What You've Built, and Where to Take It</h2>
<p>You've built a complete system: browsing history flows in through capture, gets cleaned and segmented into sessions, clustered and scored into intent threads, optionally labeled and grounded by AI, and surfaced through a dashboard with a conversational assistant. Every stage runs on your own machine, and the AI layers are optional additions on top of a pipeline that works without them.</p>
<p>If the clustering reminds you of Chrome's old <a href="https://blog.google/products-and-platforms/products/chrome/finding-answers-gets-better-chrome/">Journeys</a> feature, that's a fair connection. Grouping history by topic instead of by time is the same starting point.</p>
<p>openloops takes it further: every thread carries a confidence score and a status, the AI layer adds labels and a concrete next step, the assistant reasons across threads on demand, and the whole thing is open source and local-first. This means that you can read and change exactly what it does with your data.</p>
<h3 id="heading-what-the-privacy-model-adds-up-to">What the Privacy Model Adds Up To</h3>
<p>Privacy shaped the build at every step, and it's worth collecting what that amounted to in one place. The entire core pipeline, capture through scored threads, runs locally in IndexedDB with no network calls of any kind. Your browsing history – the raw events, the sessions, the threads – never leaves your machine for the parts of the system that work without a key.</p>
<p>The two AI layers are the only paths by which any data leaves the device, and both are opt-in, gated on you providing your own API key. When they run, what they send is deliberately minimal: brand enrichment sends only bare domain names to context.dev, never URLs or page contents, and stripped of any local addresses first. Labeling and the assistant send thread titles, summaries, keywords, and sample page titles to Anthropic, the grounding context you can read directly in the code, and nothing more. Keys themselves live in <code>chrome.storage.local</code>, which never syncs.</p>
<h3 id="heading-where-to-take-it-next">Where to Take it Next</h3>
<p>The build leaves a few deliberate simplifications that make good exercises.</p>
<p>The most satisfying one builds directly on code you've already written. The domain side has <code>ambient.ts</code>, which drops domains that appear on most of your active days. But the keyword side has no equivalent, so a word that's ubiquitous <em>for you</em> (say <code>typescript</code>, if you're a TypeScript developer) survives in every session's keywords and can nudge unrelated threads together.</p>
<p>The fix is a frequency-based keyword detector that mirrors <code>detectAmbientDomains</code> almost line for line, counting days-per-keyword instead of days-per-domain:</p>
<pre><code class="language-typescript">export function detectAmbientKeywords(sessions: Session[]): Set&lt;string&gt; {
  const allEvents = sessions.flatMap((s) =&gt; s.events);
  const activeDays = new Set(allEvents.map((e) =&gt; new Date(e.visitedAt).toDateString()));
  const totalActiveDays = activeDays.size;
  if (totalActiveDays &lt; MIN_ACTIVE_DAYS) return new Set();

  const keywordDayMap = new Map&lt;string, Set&lt;string&gt;&gt;();
  for (const session of sessions) {
    const day = new Date(session.startedAt).toDateString();
    for (const kw of session.keywords) {
      if (!keywordDayMap.has(kw)) keywordDayMap.set(kw, new Set());
      keywordDayMap.get(kw)!.add(day);
    }
  }

  const ambient = new Set&lt;string&gt;();
  for (const [kw, days] of keywordDayMap) {
    if (days.size / totalActiveDays &gt;= UBIQUITY_THRESHOLD) ambient.add(kw);
  }
  return ambient;
}
</code></pre>
<p>You'd then strip these keywords inside <code>similarity</code> exactly as ambient domains are stripped today, filtering them out of both <code>sessionKeywords</code> and the thread's <code>keywordSet</code> before the Jaccard call.</p>
<p>Two smaller exercises round it out. The session gap, similarity threshold, and ambient ubiquity threshold are all hardcoded constants. Lifting them into a settings panel backed by <code>chrome.storage.local</code> (the same store the API keys already use) would let you tune clustering to your own browsing.</p>
<p>And <code>extractDomain</code> strips only a leading <code>www.</code>, so <code>news.bbc.co.uk</code> and <code>bbc.co.uk</code> are treated as different domains. Swapping its hostname logic for a library that uses the <a href="https://publicsuffix.org/">Public Suffix List</a> (the canonical list of domain suffixes like <code>.co.uk</code> that browsers use to know where a registrable domain actually ends) would collapse subdomains of the same site correctly.</p>
<p>Since the whole pipeline is local and inspectable, each of these is straightforward to try against your own real data and see the effect immediately.</p>
<h2 id="heading-wrapping-up">Wrapping up</h2>
<p>openloops turns the flat, chronological record your browser keeps into a map of what you were actually trying to do, and helps you close the loops you left open.</p>
<p>The engineering underneath&nbsp;– time-gap segmentation, weighted Jaccard clustering with ambient-domain correction, heuristic scoring, AI labeling grounded in real company data, and a conversational layer over the result – is the kind of layered system where each stage is simple on its own and the value comes from how they compose.</p>
<h2 id="heading-resources">Resources</h2>
<h3 id="heading-source-code">Source Code</h3>
<ul>
<li>The complete source is available on <a href="https://github.com/sholajegede/openloops">GitHub</a> under the MIT license, so you can run it, read it, and reshape it to fit how you browse. If it helped you, consider giving it a star.</li>
</ul>
<h3 id="heading-core-documentation">Core Documentation</h3>
<ul>
<li><p><a href="https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3">Chrome Extensions: Manifest V3</a>: the extension platform openloops is built on</p>
</li>
<li><p><a href="https://developer.chrome.com/docs/extensions/reference/api/history">chrome.history API</a>: the <code>search</code> and <code>getVisits</code> methods the backfill relies on</p>
</li>
<li><p><a href="https://developer.chrome.com/docs/extensions/reference/api/tabs">chrome.tabs API</a>: <code>onUpdated</code> for live capture and <code>create</code> for Resume</p>
</li>
<li><p><a href="http://chrome.storage">chrome.storage</a> <a href="https://developer.chrome.com/docs/extensions/reference/api/storage">API</a>: where API keys and preferences live, locally</p>
</li>
<li><p><a href="https://docs.claude.com/en/api/messages">Anthropic API reference</a>: the Messages endpoint used for labeling and the assistant</p>
</li>
</ul>
<h3 id="heading-services-used">Services used</h3>
<ul>
<li><p><a href="https://console.anthropic.com/settings/keys">Anthropic Console</a>: create the API key for AI labeling and the assistant</p>
</li>
<li><p><a href="http://context.dev">context.dev</a> <a href="https://docs.context.dev">documentation</a>: the brand-intelligence API used for grounding</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">IndexedDB (MDN)</a>: the local database every pipeline stage reads and writes</p>
</li>
</ul>
<h3 id="heading-build-tooling">Build tooling</h3>
<ul>
<li><p><a href="https://vitejs.dev/">Vite</a>: the build tool and dev server</p>
</li>
<li><p><a href="https://crxjs.dev/vite-plugin">CRXJS Vite plugin</a>: compiles a Manifest V3 extension with hot reloading</p>
</li>
<li><p><a href="https://github.com/jakearchibald/idb">idb</a>: the typed, promise-based IndexedDB wrapper</p>
</li>
<li><p><a href="https://github.com/remarkjs/react-markdown">react-markdown</a>: renders the assistant's Markdown replies</p>
</li>
</ul>
<h3 id="heading-debugging-tools">Debugging tools</h3>
<ul>
<li><p><a href="https://developer.chrome.com/docs/extensions/get-started/tutorial/debug">Chrome extension service worker DevTools</a>: inspect live-capture logs and the pipeline <code>console.table</code> output</p>
</li>
<li><p>The <strong>Application → IndexedDB</strong> panel in Chrome DevTools: browse <code>raw_events</code>, <code>sessions</code>, <code>intent_threads</code>, and <code>domain_brands</code> directly to verify each stage</p>
</li>
</ul>
<h3 id="heading-further-reading">Further reading</h3>
<ul>
<li><p><a href="https://en.wikipedia.org/wiki/Jaccard_index">Jaccard index</a>: the set-similarity measure behind thread clustering</p>
</li>
<li><p><a href="https://publicsuffix.org/">Public Suffix List</a>: the proper way to extract registrable domains, referenced as a future improvement</p>
</li>
</ul>
<p>If this tutorial was useful, feel free to share it with others who might benefit. I'd really appreciate your thoughts, you can mention me on X at <a href="https://x.com/wani_shola">@wani_shola</a> or <a href="https://linkedin.com/in/sholajegede">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Develop Chrome Extensions using Plasmo [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ Chrome extensions are lightweight tools that enhance and personalize your browsing experience, whether that's managing passwords, translating pages, or adding entirely new features to websites you use ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-develop-chrome-extensions-using-plasmo-handbook/</link>
                <guid isPermaLink="false">6a0237edfca21b0d4b636175</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google Chrome ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Preston Mayieka ]]>
                </dc:creator>
                <pubDate>Mon, 11 May 2026 20:11:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e0d0bca4-a2e8-495a-9c1c-4f0b9ef52630.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Chrome extensions are lightweight tools that enhance and personalize your browsing experience, whether that's managing passwords, translating pages, or adding entirely new features to websites you use every day.</p>
<p>Millions of developers have published extensions to the Chrome Web Store, and building one is more approachable than you might think.</p>
<p>In this handbook you'll go from zero to a published Chrome extension using TypeScript, React, and Plasmo, a modern framework that handles the repetitive setup and configuration so you can focus on writing features instead of boilerplate.</p>
<p>Along the way you'll touch the real Chrome extension APIs that power production extensions: querying tabs, creating tab groups, and passing messages between different parts of an extension.</p>
<p>By the end you'll have working code, a mental model of how extensions are structured, and everything you need to publish your own ideas to the Chrome Web Store.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-plasmo">What is Plasmo?</a></p>
</li>
<li><p><a href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="#heading-what-you-will-learn">What You Will Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-understanding-the-background-script">Understanding the Background Script</a></p>
</li>
<li><p><a href="#heading-building-the-popup-ui">Building the Popup UI</a></p>
</li>
<li><p><a href="#heading-testing-your-extension">Testing Your Extension</a></p>
</li>
<li><p><a href="#heading-next-steps-and-extension-ideas">Next Steps and Extension Ideas</a></p>
</li>
<li><p><a href="#heading-deploying-to-chrome-web-store">Deploying to Chrome Web Store</a></p>
</li>
</ul>
<h2 id="heading-what-is-plasmo">What is Plasmo?</h2>
<p><a href="https://www.plasmo.com/">Plasmo</a> is an open-source framework for building browser extensions. Think of it as the equivalent of Create React App or Next.js, but for Chrome extensions.</p>
<p>Without Plasmo, building a Chrome extension requires manually writing a <code>manifest.json</code> file, wiring up build tooling, and configuring TypeScript and React yourself. Plasmo handles all of that.</p>
<p>A single command scaffolds a working project with TypeScript and React already configured. It reads your <code>package.json</code> and generates the <code>manifest.json</code> Chrome requires, so you never edit it directly.</p>
<p>Moreover, changes to your source files automatically rebuild and reload the extension in Chrome during development, and full type safety including types for Chrome's own APIs is available out of the box.</p>
<p>Plasmo doesn't hide the Chrome extension concepts from you. You still use <code>chrome.tabs</code>, <code>chrome.runtime</code>, and the rest of the Chrome APIs directly. It just removes the tedious scaffolding so you can start building immediately.</p>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>In this tutorial, you'll build a <strong>Tab Grouper</strong> Chrome extension from scratch.</p>
<p>This extension automatically organizes your browser tabs by grouping them based on their website domain.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/43f51cde-41c8-46ac-9305-6b4ad5adc1ac.gif" alt="Animated demo of the Tab Grouper extension grouping open tabs into colored groups by domain" style="display:block;margin:0 auto" width="800" height="520" loading="lazy">

<h3 id="heading-example-use-case">Example Use Case</h3>
<p>Imagine you have 20 tabs open: 5 from GitHub, 4 from YouTube, 3 from Stack Overflow, and 8 from other websites.</p>
<p>With one click, the Tab Grouper extension will automatically create colored groups for each website, making it straightforward to find and manage your tabs.</p>
<h2 id="heading-what-you-will-learn">What You Will Learn</h2>
<p>By completing this tutorial, you'll get hands-on experience in three areas.</p>
<p>First, <strong>Chrome Extension Basics</strong>: how extensions work under the hood, the anatomy of an extension (manifest, background scripts, popups), and how to load and test extensions in Chrome during development.</p>
<p>Second, <strong>Chrome APIs</strong>: specifically <code>chrome.tabs</code> for managing browser tabs, <code>chrome.tabGroups</code> for creating and customizing tab groups, and <code>chrome.runtime</code> for passing messages between different parts of your extension.</p>
<p>Third, <strong>Modern Web Development tooling</strong>: TypeScript for type-safe JavaScript, React for building the popup UI, and the Plasmo framework that ties it all together.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You don't need to be an expert in any of these, but you'll have the smoothest experience if you're comfortable with basic JavaScript or TypeScript and have a general understanding of HTML and CSS.</p>
<p>Some familiarity with React is helpful but not required. The pop-up component we'll build is simple enough to follow even if you're new to it.</p>
<p>On the software side, you'll need Node.js version 18 or higher (<a href="https://nodejs.org/">download here</a>), Google Chrome, a code editor (VS Code is recommended), and pnpm as your package manager.</p>
<h3 id="heading-verify-your-setup">Verify Your Setup</h3>
<p>Open your terminal and run these commands to confirm everything is installed:</p>
<pre><code class="language-bash">node --version
# Should output v18.0.0 or higher

npm --version
# Should output 9.0.0 or higher
</code></pre>
<h3 id="heading-getting-help">Getting Help</h3>
<p>If you get stuck, review the complete code in the repository, consult the Chrome Extension documentation, or ask for help in the community forums.</p>
<h3 id="heading-ready-to-begin">Ready to Begin?</h3>
<p>In the next section, you'll set up your development environment and create your first Chrome extension project.</p>
<p>Let's get started!</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>In this section, you'll use Plasmo to scaffold your Chrome extension project, then customize it for the Tab Grouper.</p>
<p>Rather than creating files manually, you'll let Plasmo generate a starter project with all required configuration, then explore what was created before customizing it for our needs.</p>
<h2 id="heading-step-1-install-pnpm-recommended">Step 1: Install pnpm (Recommended)</h2>
<p>Plasmo officially recommends <strong>pnpm</strong> for faster installs and better disk space usage. Check if you already have it:</p>
<pre><code class="language-bash">pnpm --version
</code></pre>
<p>If you see a version number, skip to Step 2.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/aeed7b06-a403-4fe2-81fe-571a00219acf.png" alt="Terminal output showing pnpm version number after running pnpm --version" style="display:block;margin:0 auto" width="1126" height="460" loading="lazy">

<p>If you get "command not found", install it with:</p>
<pre><code class="language-bash">npm install -g pnpm
</code></pre>
<h2 id="heading-step-2-create-your-extension-project">Step 2: Create Your Extension Project</h2>
<p>Run this command to create a new Plasmo project:</p>
<pre><code class="language-bash">pnpm create plasmo tab-grouper
</code></pre>
<p>You'll see:</p>
<pre><code class="language-plaintext">🟣 Creating a new Plasmo extension
📁 Project name: tab-grouper
? Extension description: (Give your extension a nice description)
? Author name: (Your Name)
</code></pre>
<p>Plasmo will then scaffold the project and install dependencies automatically. You might be prompted to enter a description and author name.</p>
<p>Fill these in however you like.</p>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/e0a58818-0bec-42a7-bde3-c7a66de68b7a.png" alt="Terminal output showing Plasmo scaffolding a new project called tab-grouper and installing dependencies." style="display:block;margin:0 auto" width="1652" height="530" loading="lazy">

<h3 id="heading-step-3-navigate-to-your-project">Step 3: Navigate to Your Project</h3>
<pre><code class="language-bash">cd tab-grouper
</code></pre>
<h3 id="heading-step-4-explore-what-was-created">Step 4: Explore What Was Created</h3>
<p>List the files that Plasmo generated:</p>
<pre><code class="language-bash">ls -la
</code></pre>
<p>You should see something like this:</p>
<pre><code class="language-plaintext">tab-grouper/
├── .git/                 # Git repository (already initialized!)
├── .github/              # GitHub Actions workflows
├── assets/
│   └── icon.png          # Default Plasmo icon 
├── node_modules/         # Dependencies (already installed!)
├── package.json          # Project configuration
├── popup.tsx             # Default popup 
├── .prettierrc.cjs       # Code formatting rules
├── .gitignore            # Git ignore rules
├── README.md             # Default readme
└── tsconfig.json         # TypeScript configuration
</code></pre>
<p>The key files to know about:</p>
<ul>
<li><p><strong>assets/icon.png</strong>: The extension icon required by Chrome.</p>
</li>
<li><p><strong>package.json</strong>: Lists dependencies and scripts, and is where you configure the extension manifest.</p>
</li>
<li><p><strong>popup.tsx</strong>: The UI that appears when you click the extension icon.</p>
</li>
<li><p><strong>tsconfig.json</strong>: Contains TypeScript settings that are already correctly configured.</p>
</li>
</ul>
<h3 id="heading-step-5-test-the-default-extension">Step 5: Test the Default Extension</h3>
<p>Make sure everything works <strong>before</strong> you customize it.</p>
<p>You can do this by starting the development server:</p>
<pre><code class="language-bash">pnpm dev
</code></pre>
<p>You should see output like this:</p>
<pre><code class="language-plaintext">🟣 Plasmo v0.90.5
🔴 The Browser Extension Framework
🔵 INFO   | Starting the extension development server...
🔵 INFO   | Building for target: chrome-mv3
🔵 INFO   | Loaded environment variables from: []
🟢 DONE   | Extension re-packaged in 1842ms! 🚀

View Extension:
📦 build/chrome-mv3-dev
</code></pre>
<p>Your extension is ready. Keep this terminal window open.</p>
<p>Plasmo watches for file changes and rebuilds automatically.</p>
<h3 id="heading-step-6-load-the-extension-in-chrome">Step 6: Load the Extension in Chrome</h3>
<p>Now load the extension into Chrome to test it:</p>
<ol>
<li><p>Open Google Chrome</p>
</li>
<li><p>Go to <code>chrome://extensions/</code></p>
</li>
<li><p>Enable <strong>Developer mode</strong> (toggle in top-right)</p>
</li>
<li><p>Click <strong>"Load unpacked"</strong></p>
</li>
<li><p>Navigate to your project folder</p>
</li>
<li><p>Select the <code>build/chrome-mv3-dev</code> folder</p>
</li>
<li><p>Click "Select Folder"</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/19cef596-a9d1-4709-8d27-594381d03842.gif" alt="Animated gif showing how to load an unpacked extension in Chrome via the Extensions page developer mode" style="display:block;margin:0 auto" width="800" height="461" loading="lazy">

<p>Your extension should now appear in the list.</p>
<h3 id="heading-step-7-test-the-default-popup">Step 7: Test the Default Popup</h3>
<ol>
<li><p>Click the puzzle piece icon in Chrome's toolbar</p>
</li>
<li><p>Find "tab-grouper" and pin it</p>
</li>
<li><p>Click the extension icon</p>
</li>
</ol>
<p>You will see a default popup that says "Welcome to Plasmo!"</p>
<img src="https://cdn.hashnode.com/uploads/covers/64ef9ca6a3a26476fe998b69/56bad298-b07e-41c5-a648-49e382e0c51b.png" alt="The default Plasmo popup showing a Welcome to Plasmo message in the Chrome toolbar popup" style="display:block;margin:0 auto" width="846" height="616" loading="lazy">

<p>The extension is working. Now you can customize it.</p>
<h3 id="heading-step-8-update-extension-information">Step 8: Update Extension Information</h3>
<p>Open <code>package.json</code> in your editor. This file stores metadata about your project. name, version, description, dependencies, and scripts for building and running your extension.</p>
<p>Find these lines near the top:</p>
<pre><code class="language-json">{
  "name": "tab-grouper",
  "displayName": "tab-grouper",
  "version": "0.0.0",
  "description": "A basic Plasmo extension.",
</code></pre>
<p>Change them to:</p>
<pre><code class="language-json">{
  "name": "tab-grouper",
  "displayName": "Tab Grouper",
  "version": "1.0.0",
  "description": "A simple Chrome extension - group tabs by domain",
</code></pre>
<p>Save the file.</p>
<h3 id="heading-step-9-add-required-permissions-critical">Step 9: Add Required Permissions (Critical!)</h3>
<p><strong>This is a critical step.</strong> Without permissions, your extension will fail with errors like:</p>
<pre><code class="language-plaintext">TypeError: Cannot read properties of undefined (reading 'query')
</code></pre>
<p>Chrome extensions must declare which browser APIs they intend to use. In <code>package.json</code>, find the <code>"manifest"</code> section.</p>
<p>It looks like this:</p>
<pre><code class="language-json">"manifest": {
  "host_permissions": [
    "https://*/*"
  ]
}
</code></pre>
<p>Replace it with:</p>
<pre><code class="language-json">"manifest": {
  "permissions": [
    "tabs",
    "tabGroups"
  ]
}
</code></pre>
<p>Save the file. The <code>tabs</code> permission allows you to read tab information (required for <code>chrome.tabs.query()</code>), and <code>tabGroups</code> allows you to create and manage tab groups (required for <code>chrome.tabGroups.update()</code>).</p>
<h3 id="heading-finding-the-right-permissions-for-your-own-extensions">Finding the right permissions for your own extensions:</h3>
<p>The <a href="https://developer.chrome.com/docs/extensions/reference/permissions-list">Chrome Extension Permissions Reference</a> lists every available permission and what it unlocks.</p>
<p>Each API's documentation page also lists which permissions it requires, for example, the <a href="https://developer.chrome.com/docs/extensions/reference/api/tabs">chrome.tabs API page</a> specifies the <code>"tabs"</code> permission.</p>
<p>If you're using Plasmo, the <a href="https://docs.plasmo.com/framework/customization/manifest">Manifest Configuration docs</a> explain how to add permissions through <code>package.json</code>.</p>
<p>As a general rule: if you're getting <code>undefined</code> errors when calling a Chrome API, a missing permission is the first thing to check.</p>
<h3 id="heading-step-10-verify-hot-reload-works">Step 10: Verify Hot Reload Works</h3>
<p>Plasmo automatically reloads your extension when you save changes.</p>
<p>Check the terminal where <code>pnpm dev</code> is running. After saving <code>package.json</code> you should see something like:</p>
<pre><code class="language-plaintext">🔄 Reloading extension...
✅ Ready in 0.8s
</code></pre>
<p>Your project is now ready: a working extension loaded in Chrome, a development server running with hot reload, and the required permissions in place.</p>
<p>Leave the dev server running and the extension loaded as you work through the next sections. Your changes will reload automatically.</p>
<h3 id="heading-section-summary">Section Summary</h3>
<p>In this section you installed pnpm, scaffolded a new extension with <code>pnpm create plasmo</code>, explored the generated project structure, started the development server, loaded the extension in Chrome, and updated the extension metadata and permissions.</p>
<p><strong>Next:</strong> You'll create the background script that handles the tab grouping logic.</p>
<h2 id="heading-understanding-the-background-script">Understanding the Background Script</h2>
<p>The background script is the heart of your extension. It runs persistently behind the scenes and contains the core logic.</p>
<p>In this case, the code that groups your tabs by domain.</p>
<h3 id="heading-what-is-a-background-script">What is a Background Script?</h3>
<p>A background script runs continuously even when the popup is closed.</p>
<p>It can listen to browser events like tabs opening, closing, or updating, perform tasks that don't require direct user interaction, and communicate with other parts of the extension by passing messages.</p>
<p>Think of it as the server-side of your extension. The popup is just a UI that talks to it.</p>
<h3 id="heading-step-1-create-backgroundts">Step 1: Create background.ts</h3>
<p>Plasmo's scaffolding didn't create a background script by default, so you'll create this file from scratch. Create a new file called <code>background.ts</code> in your project root (the same level as <code>popup.tsx</code>):</p>
<pre><code class="language-typescript">export {}

// Background script - runs in the background and handles tab grouping logic

console.log("Tab Grouper background script loaded!")

// Listen for messages from the popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) =&gt; {
  if (message.type === "GROUP_TABS") {
    groupTabsByDomain()
    sendResponse({ success: true })
  }
  return true
})
</code></pre>
<p>The <code>export {}</code> at the top is required by Plasmo to treat this file as a module. Without it you may get errors about conflicting global variable declarations.</p>
<p>The <code>console.log</code> will help you verify the script loaded correctly (you'll see it in the extension's DevTools console). <code>chrome.runtime.onMessage</code> sets up a listener so the background script can receive instructions from the popup.</p>
<p>When it receives a <code>"GROUP_TABS"</code> message, it calls the grouping function.</p>
<p>You can read more about this messaging pattern in the <a href="https://developer.chrome.com/docs/extensions/develop/concepts/messaging">Chrome Extensions documentation</a>.</p>
<h3 id="heading-step-2-implement-tab-grouping-logic">Step 2: Implement Tab Grouping Logic</h3>
<p>Now add the main grouping function below the message listener:</p>
<pre><code class="language-typescript">async function groupTabsByDomain() {
  try {
    // Step 1: Get all tabs in the current window
    const tabs = await chrome.tabs.query({ currentWindow: true })

    // Step 2: Create a Map to organize tabs by domain
    const domainGroups = new Map&lt;string, chrome.tabs.Tab[]&gt;()

    // Step 3: Loop through each tab and group by domain
    tabs.forEach(tab =&gt; {
      // Skip tabs without URLs
      if (!tab.url) return

      // Extract the domain from the URL
      const domain = getDomainFromUrl(tab.url)

      // Skip invalid domains (like chrome:// pages)
      if (!domain) return

      // Add tab to the appropriate domain group
      if (!domainGroups.has(domain)) {
        domainGroups.set(domain, [])
      }
      domainGroups.get(domain)!.push(tab)
    })

    // Step 4: Create tab groups for each domain (only if 2+ tabs)
    for (const [domain, domainTabs] of domainGroups) {
      // Skip domains with only 1 tab
      if (domainTabs.length &lt; 2) continue

      // Get all tab IDs
      const tabIds = domainTabs
        .map(t =&gt; t.id!)
        .filter(id =&gt; id !== undefined)

      if (tabIds.length === 0) continue

      // Create the tab group
      const groupId = await chrome.tabs.group({ tabIds })

      // Customize the group with a title and color
      await chrome.tabGroups.update(groupId, {
        title: domain,
        color: getColorForDomain(domain) // Randomized Tab Group colors.
      })
    }

    console.log(`Successfully grouped ${domainGroups.size} domains`)
  } catch (error) {
    console.error("Error grouping tabs:", error)
  }
}
</code></pre>
<p>The function starts by querying all tabs in the current window, then iterates over them to build a <code>Map</code> keyed by domain name.</p>
<p>Once every tab has been sorted into a domain bucket, it loops through the map and calls <code>chrome.tabs.group()</code> for any domain that has two or more tabs, then immediately customizes the resulting group with a title and color.</p>
<p>Domains with only a single tab are skipped. There's no point grouping a lone tab.</p>
<h3 id="heading-step-3-extract-domain-helper">Step 3: Extract Domain Helper</h3>
<p>Add a helper function to pull the hostname out of a URL:</p>
<pre><code class="language-typescript">function getDomainFromUrl(url: string): string | null {
  try {
    const urlObj = new URL(url)

    // Skip Chrome internal pages (chrome://, chrome-extension://)
    if (urlObj.protocol === "chrome:" || urlObj.protocol === "chrome-extension:") {
      return null
    }

    // Remove "www." prefix and return the hostname
    return urlObj.hostname.replace(/^www\./, "")
  } catch {
    // Return null if URL is invalid
    return null
  }
}
</code></pre>
<p><code>new URL(url)</code> gives us a structured object to work with rather than string-parsing the URL manually.</p>
<p>The protocol check filters out Chrome's internal pages like <code>chrome://extensions</code> and <code>chrome://settings</code>, which extensions can't access.</p>
<p>The <code>.replace(/^www\./, "")</code> ensures that <code>www.github.com</code> and <code>github.com</code> are treated as the same domain rather than two separate groups.</p>
<p>The whole thing is wrapped in a try-catch so malformed URLs simply return <code>null</code> and get skipped.</p>
<p>In practice: <code>https://www.github.com/user/repo</code> becomes <code>github.com</code>, <code>https://youtube.com/watch?v=123</code> becomes <code>youtube.com</code>, and <code>chrome://extensions</code> returns <code>null</code>.</p>
<h3 id="heading-step-4-color-assignment-helper">Step 4: Color Assignment Helper</h3>
<p>Add a function to deterministically assign a color to each domain:</p>
<pre><code class="language-typescript">function getColorForDomain(domain: string): chrome.tabGroups.ColorEnum {
  // Available colors in Chrome
  const colors: chrome.tabGroups.ColorEnum[] = [
    "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"
  ]

  // Create a simple hash from the domain name
  let hash = 0
  for (let i = 0; i &lt; domain.length; i++) {
    hash = domain.charCodeAt(i) + ((hash &lt;&lt; 5) - hash)
  }

  // Return a color based on the hash
  return colors[Math.abs(hash) % colors.length]
}
</code></pre>
<p>Chrome supports eight colors for tab groups. Rather than assigning them randomly (which would change every time you group), this function hashes the domain name to a number and uses the modulo operator to pick a consistent index into the color array.</p>
<p>The result is that <code>github.com</code> always gets the same color across sessions, while different domains are likely to get different colors.</p>
<h3 id="heading-complete-backgroundts-file">Complete background.ts File</h3>
<p>Your complete <code>background.ts</code> should look like this:</p>
<pre><code class="language-typescript">export {}

console.log("Tab Grouper background script loaded!")

chrome.runtime.onMessage.addListener((message, sender, sendResponse) =&gt; {
  if (message.type === "GROUP_TABS") {
    groupTabsByDomain()
    sendResponse({ success: true })
  }
  return true
})

async function groupTabsByDomain() {
  try {
    const tabs = await chrome.tabs.query({ currentWindow: true })
    const domainGroups = new Map&lt;string, chrome.tabs.Tab[]&gt;()

    tabs.forEach(tab =&gt; {
      if (!tab.url) return
      const domain = getDomainFromUrl(tab.url)
      if (!domain) return

      if (!domainGroups.has(domain)) {
        domainGroups.set(domain, [])
      }
      domainGroups.get(domain)!.push(tab)
    })

    for (const [domain, domainTabs] of domainGroups) {
      if (domainTabs.length &lt; 2) continue

      const tabIds = domainTabs
        .map(t =&gt; t.id!)
        .filter(id =&gt; id !== undefined)

      if (tabIds.length === 0) continue

      const groupId = await chrome.tabs.group({ tabIds })

      await chrome.tabGroups.update(groupId, {
        title: domain,
        color: getColorForDomain(domain)
      })
    }

    console.log(`Successfully grouped ${domainGroups.size} domains`)
  } catch (error) {
    console.error("Error grouping tabs:", error)
  }
}

function getDomainFromUrl(url: string): string | null {
  try {
    const urlObj = new URL(url)
    if (urlObj.protocol === "chrome:" || urlObj.protocol === "chrome-extension:") {
      return null
    }
    return urlObj.hostname.replace(/^www\./, "")
  } catch {
    return null
  }
}

function getColorForDomain(domain: string): chrome.tabGroups.ColorEnum {
  const colors: chrome.tabGroups.ColorEnum[] = [
    "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"
  ]

  let hash = 0
  for (let i = 0; i &lt; domain.length; i++) {
    hash = domain.charCodeAt(i) + ((hash &lt;&lt; 5) - hash)
  }

  return colors[Math.abs(hash) % colors.length]
}
</code></pre>
<h3 id="heading-testing-the-background-script">Testing the Background Script</h3>
<p>If your development server isn't already running from the previous section, start it:</p>
<pre><code class="language-bash">pnpm dev
</code></pre>
<p>To verify the background script loaded correctly, go to <code>chrome://extensions</code>, find "Tab Grouper Tutorial", and click the <strong>"service worker"</strong> link.</p>
<p>A DevTools console will open and you should see "Tab Grouper background script loaded!" confirming everything is wired up.</p>
<h2 id="heading-building-the-popup-ui">Building the Popup UI</h2>
<p>The popup is the small window that appears when a user clicks your extension icon in the Chrome toolbar.</p>
<p>It can display information, provide buttons for actions, and show settings.</p>
<p>In this section you'll build a React-based popup that shows live tab statistics and triggers the grouping logic in the background script.</p>
<h3 id="heading-step-1-replace-popuptsx">Step 1: Replace popup.tsx</h3>
<p>When you ran <code>pnpm create plasmo</code>, a default <code>popup.tsx</code> was created that just displays a welcome message.</p>
<p>Open that file and replace <strong>all</strong> of its contents with this starting skeleton:</p>
<pre><code class="language-tsx">import { useState, useEffect } from "react"

function IndexPopup() {
  const [tabCount, setTabCount] = useState(0)
  const [groupCount, setGroupCount] = useState(0)
  const [isGrouping, setIsGrouping] = useState(false)

  return (
    &lt;div&gt;
      &lt;h2&gt;Tab Grouper&lt;/h2&gt;
      &lt;button&gt;Group Tabs&lt;/button&gt;
    &lt;/div&gt;
  )
}

export default IndexPopup
</code></pre>
<p>Save the file and the extension will automatically reload.</p>
<p>The three state variables track the number of open tabs, the number of existing groups, and whether a grouping operation is currently in progress.</p>
<p>That last one lets us disable the button and show a loading state so users can't trigger multiple groupings at once.</p>
<h3 id="heading-step-2-load-statistics">Step 2: Load Statistics</h3>
<p>Now add the logic to load tab and group counts when the popup opens. Add this inside the <code>IndexPopup</code> function, right after the state declarations:</p>
<pre><code class="language-tsx">// Load tab statistics when popup opens
useEffect(() =&gt; {
  loadStats()
}, [])

async function loadStats() {
  const tabs = await chrome.tabs.query({ currentWindow: true })
  const groups = await chrome.tabGroups.query({
    windowId: chrome.windows.WINDOW_ID_CURRENT
  })

  setTabCount(tabs.length)
  setGroupCount(groups.length)
}
</code></pre>
<p>The <code>useEffect</code> with an empty dependency array <code>[]</code> runs once when the component first mounts. In other words, every time the popup opens.</p>
<p>It calls <code>loadStats</code>, which queries Chrome for the current window's tabs and groups, then updates the state variables with the counts.</p>
<h3 id="heading-step-3-trigger-tab-grouping">Step 3: Trigger Tab Grouping</h3>
<p>Add the handler that sends a message to the background script when the button is clicked:</p>
<pre><code class="language-tsx">async function handleGroupTabs() {
  setIsGrouping(true)

  // Send message to background script
  await chrome.runtime.sendMessage({ type: "GROUP_TABS" })

  // Refresh statistics
  await loadStats()
  setIsGrouping(false)
}
</code></pre>
<p><code>chrome.runtime.sendMessage</code> delivers the <code>{ type: "GROUP_TABS" }</code> message to the listener we set up in <code>background.ts</code>.</p>
<p>After the background script finishes, we reload the statistics so the group count updates immediately, then re-enable the button.</p>
<h3 id="heading-step-4-build-the-ui">Step 4: Build the UI</h3>
<p>Replace the placeholder <code>return</code> statement with this complete, styled version:</p>
<pre><code class="language-tsx">return (
  &lt;div style={{
    width: 300,
    padding: 20,
    fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
  }}&gt;
    {/* Header */}
    &lt;div style={{ marginBottom: 20 }}&gt;
      &lt;h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}&gt;
        🗂️ Tab Grouper
      &lt;/h2&gt;
      &lt;p style={{ margin: "8px 0 0", fontSize: 13, color: "#666" }}&gt;
        Organize your tabs by domain
      &lt;/p&gt;
    &lt;/div&gt;

    {/* Statistics */}
    &lt;div style={{
      display: "flex",
      gap: 12,
      marginBottom: 20,
      padding: 12,
      background: "#f5f5f5",
      borderRadius: 8
    }}&gt;
      &lt;div style={{ flex: 1 }}&gt;
        &lt;div style={{ fontSize: 24, fontWeight: 600, color: "#333" }}&gt;
          {tabCount}
        &lt;/div&gt;
        &lt;div style={{ fontSize: 12, color: "#666" }}&gt;
          Open Tabs
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;div style={{ flex: 1 }}&gt;
        &lt;div style={{ fontSize: 24, fontWeight: 600, color: "#0066ff" }}&gt;
          {groupCount}
        &lt;/div&gt;
        &lt;div style={{ fontSize: 12, color: "#666" }}&gt;
          Tab Groups
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;

    {/* Group Button */}
    &lt;button
      onClick={handleGroupTabs}
      disabled={isGrouping}
      style={{
        width: "100%",
        padding: "12px 16px",
        fontSize: 14,
        fontWeight: 500,
        color: "white",
        background: isGrouping ? "#ccc" : "#0066ff",
        border: "none",
        borderRadius: 8,
        cursor: isGrouping ? "not-allowed" : "pointer",
        transition: "background 0.2s"
      }}
    &gt;
      {isGrouping ? "Grouping..." : "🗂️ Group Tabs by Domain"}
    &lt;/button&gt;

    {/* Footer */}
    &lt;div style={{
      marginTop: 16,
      padding: 12,
      fontSize: 12,
      color: "#666",
      background: "#fff9e6",
      borderRadius: 6,
      border: "1px solid #ffe066"
    }}&gt;
      💡 &lt;strong&gt;Tip:&lt;/strong&gt; This will group all tabs in this window by their website domain.
    &lt;/div&gt;
  &lt;/div&gt;
)
</code></pre>
<p>The UI has four parts: a header with the extension title and a short description, a statistics box showing the live tab and group counts side by side, the main action button (which grays out and changes text to "Grouping..." while work is in progress), and a tip box at the bottom.</p>
<p>This tutorial uses inline styles for simplicity. In a production extension, you'd likely reach for CSS modules, Tailwind, or styled-components instead.</p>
<h3 id="heading-complete-popuptsx-file">Complete popup.tsx File</h3>
<p>Your complete <code>popup.tsx</code> should look like this:</p>
<pre><code class="language-tsx">import { useState, useEffect } from "react"

function IndexPopup() {
  const [tabCount, setTabCount] = useState(0)
  const [groupCount, setGroupCount] = useState(0)
  const [isGrouping, setIsGrouping] = useState(false)

  useEffect(() =&gt; {
    loadStats()
  }, [])

  async function loadStats() {
    const tabs = await chrome.tabs.query({ currentWindow: true })
    const groups = await chrome.tabGroups.query({
      windowId: chrome.windows.WINDOW_ID_CURRENT
    })

    setTabCount(tabs.length)
    setGroupCount(groups.length)
  }

  async function handleGroupTabs() {
    setIsGrouping(true)
    await chrome.runtime.sendMessage({ type: "GROUP_TABS" })
    await loadStats()
    setIsGrouping(false)
  }

  return (
    &lt;div style={{
      width: 300,
      padding: 20,
      fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
    }}&gt;
      &lt;div style={{ marginBottom: 20 }}&gt;
        &lt;h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}&gt;
          🗂️ Tab Grouper
        &lt;/h2&gt;
        &lt;p style={{ margin: "8px 0 0", fontSize: 13, color: "#666" }}&gt;
          Organize your tabs by domain
        &lt;/p&gt;
      &lt;/div&gt;

      &lt;div style={{
        display: "flex",
        gap: 12,
        marginBottom: 20,
        padding: 12,
        background: "#f5f5f5",
        borderRadius: 8
      }}&gt;
        &lt;div style={{ flex: 1 }}&gt;
          &lt;div style={{ fontSize: 24, fontWeight: 600, color: "#333" }}&gt;
            {tabCount}
          &lt;/div&gt;
          &lt;div style={{ fontSize: 12, color: "#666" }}&gt;
            Open Tabs
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;div style={{ flex: 1 }}&gt;
          &lt;div style={{ fontSize: 24, fontWeight: 600, color: "#0066ff" }}&gt;
            {groupCount}
          &lt;/div&gt;
          &lt;div style={{ fontSize: 12, color: "#666" }}&gt;
            Tab Groups
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;button
        onClick={handleGroupTabs}
        disabled={isGrouping}
        style={{
          width: "100%",
          padding: "12px 16px",
          fontSize: 14,
          fontWeight: 500,
          color: "white",
          background: isGrouping ? "#ccc" : "#0066ff",
          border: "none",
          borderRadius: 8,
          cursor: isGrouping ? "not-allowed" : "pointer",
          transition: "background 0.2s"
        }}
      &gt;
        {isGrouping ? "Grouping..." : "🗂️ Group Tabs by Domain"}
      &lt;/button&gt;

      &lt;div style={{
        marginTop: 16,
        padding: 12,
        fontSize: 12,
        color: "#666",
        background: "#fff9e6",
        borderRadius: 6,
        border: "1px solid #ffe066"
      }}&gt;
        💡 &lt;strong&gt;Tip:&lt;/strong&gt; This will group all tabs in this window by their website domain.
      &lt;/div&gt;
    &lt;/div&gt;
  )
}

export default IndexPopup
</code></pre>
<h2 id="heading-testing-your-extension">Testing Your Extension</h2>
<p>Now that you have both the background script and popup UI built, it's time to verify that everything works together in Chrome.</p>
<h3 id="heading-step-1-make-sure-the-dev-server-is-running">Step 1: Make Sure the Dev Server is Running</h3>
<p>If <code>pnpm dev</code> isn't already running from an earlier step, start it now:</p>
<pre><code class="language-bash">pnpm run dev # or pnpm dev
</code></pre>
<p>Plasmo will build the extension into <code>build/chrome-mv3-dev</code> and watch for changes.</p>
<h3 id="heading-step-2-load-the-extension-in-chrome">Step 2: Load the Extension in Chrome</h3>
<p>If you haven't already loaded the extension, go to <code>chrome://extensions/</code>, enable <strong>Developer mode</strong>, click <strong>Load unpacked</strong>, and select the <code>build/chrome-mv3-dev</code> folder.</p>
<p>Once loaded you should see the extension listed with the name "Tab Grouper Tutorial", version "1.0.0", and status Enabled.</p>
<h3 id="heading-step-3-pin-the-extension">Step 3: Pin the Extension</h3>
<p>Click the puzzle piece icon in the Chrome toolbar, find "Tab Grouper Tutorial", and click the pin icon to keep it visible.</p>
<p>The extension icon will now appear directly in your toolbar.</p>
<h3 id="heading-step-4-test-the-extension">Step 4: Test the Extension</h3>
<h4 id="heading-test-1-open-multiple-tabs">Test 1: Open Multiple Tabs</h4>
<p>Open several tabs across a few domains so there's something to group:</p>
<ol>
<li><p><code>https://github.com/topics</code>, <code>https://github.com/trending</code>, <code>https://github.com/explore</code></p>
</li>
<li><p><code>https://www.youtube.com/</code> and <code>https://www.youtube.com/trending</code></p>
</li>
<li><p><code>https://stackoverflow.com/questions</code> and <code>https://stackoverflow.com/tags</code></p>
</li>
</ol>
<p>Have at least 7 tabs open.</p>
<h4 id="heading-test-2-group-the-tabs">Test 2: Group the Tabs</h4>
<p>Click the Tab Grouper extension icon. The popup should appear showing your open tab count (7 or more) and group count (probably 0).</p>
<p>Click <strong>"Group Tabs by Domain"</strong> and watch your tabs get organized into colored groups.</p>
<h4 id="heading-test-3-verify-groups">Test 3: Verify Groups</h4>
<p>After clicking the button, GitHub tabs should be grouped together with a label like "github.com" and a consistent color, and YouTube tabs similarly.</p>
<p>Click the extension icon again, the group count should now show 2, while the tab count stays the same.</p>
<h3 id="heading-step-5-debug-the-extension">Step 5: Debug the Extension</h3>
<p>If something doesn't work, Chrome's DevTools are your best friend.</p>
<p>To inspect the background script, go to <code>chrome://extensions/</code>, find your extension, and click the <strong>"service worker"</strong> link.</p>
<p>A DevTools console opens where you can look for the "Tab Grouper background script loaded!" message and any error output in red.</p>
<p>To inspect the popup, right-click the extension icon and select <strong>"Inspect popup"</strong>. This opens DevTools for the popup specifically — check the Console tab for any errors there.</p>
<p><strong>If nothing happens when you click the button</strong>, check the background script console for errors, confirm you have at least 2 tabs from the same domain, and verify the message is being sent (look in the popup console for any <code>sendMessage</code> failures).</p>
<p><strong>If tabs aren't grouping</strong>, double-check that you added the <code>tabs</code> and <code>tabGroups</code> permissions to <code>package.json</code> and reloaded the extension after saving.</p>
<p><strong>If you see "Extension cannot access chrome://..."</strong>, that's expected behavior — extensions can't interact with Chrome's internal pages and the code skips them intentionally.</p>
<h3 id="heading-step-6-hot-reloading">Step 6: Hot Reloading</h3>
<p>One of the benefits of Plasmo is hot reloading, which allows you to update code in a running app instantly without needing to restart it manually.</p>
<p>Open <code>popup.tsx</code>, change the header emoji from 🗂️ to 📁, and save.</p>
<p>The extension reloads automatically.</p>
<p>Click the icon and you'll see the updated emoji immediately.</p>
<p>Hot reloading is advantageous because it speeds up development by letting you see changes in real time.</p>
<p>You can change the emoji back afterward if you'd like to keep the extension consistent with the rest of the tutorial examples and screenshots.</p>
<h3 id="heading-step-7-test-edge-cases">Step 7: Test Edge Cases</h3>
<p>It's worth testing a few scenarios to make sure the extension handles them gracefully.</p>
<p>If you close all tabs except one and click "Group Tabs", nothing should happen. The extension requires at least two tabs from the same domain to form a group. Opening <code>chrome://extensions</code> and <code>chrome://settings</code> and then grouping should also do nothing, since those pages are filtered out.</p>
<p>If you have one tab from <code>reddit.com</code> and one from <code>freecodecamp.org</code>, each domain appearing only once, no groups should be created.</p>
<h3 id="heading-step-8-production-build">Step 8: Production Build</h3>
<p>When you're ready to share your extension, run:</p>
<pre><code class="language-bash">pnpm run build
</code></pre>
<p>This creates a production-optimized version in <code>build/chrome-mv3-prod</code>, minified JavaScript, no development-only code, and smaller file size.</p>
<p>To verify the production build, go to <code>chrome://extensions/</code>, remove the development version, click "Load unpacked", and select <code>build/chrome-mv3-prod</code>. Test thoroughly before publishing.</p>
<p>The extension is lightweight (under 100 KB), only runs when you click the button, and has no background processes when idle.</p>
<h2 id="heading-next-steps-and-extension-ideas">Next Steps and Extension Ideas</h2>
<p>Congratulations on building your first Chrome extension!</p>
<p>You now have a working tool that groups tabs by domain with one click, shows live statistics about open tabs and groups, and is built on modern tooling: TypeScript, React, and Plasmo following Chrome extension best practices.</p>
<p>The extension is a solid foundation. Here are some ideas for where to take it next.</p>
<h3 id="heading-1-auto-grouping">1. Auto-Grouping</h3>
<p>Instead of requiring a button click, you could automatically group new tabs as they're opened. You'd listen for the <code>chrome.tabs.onCreated</code> event in <code>background.ts</code> and trigger <code>groupTabsByDomain()</code> with a short delay to let the page URL load:</p>
<pre><code class="language-typescript">// In background.ts
chrome.tabs.onCreated.addListener(async (tab) =&gt; {
  // Wait a bit for the URL to load
  setTimeout(() =&gt; {
    groupTabsByDomain()
  }, 2000)
})
</code></pre>
<p>This gets into event listeners, asynchronous timing, and thinking carefully about when to fire — a good next step for understanding how background scripts can be more proactive.</p>
<h3 id="heading-2-keyboard-shortcuts">2. Keyboard Shortcuts</h3>
<p>You can trigger grouping without even opening the popup by adding a keyboard shortcut. Add a <code>commands</code> section to the manifest in <code>package.json</code>:</p>
<pre><code class="language-json">"manifest": {
  "commands": {
    "group-tabs": {
      "suggested_key": {
        "default": "Ctrl+Shift+G",
        "mac": "Command+Shift+G"
      },
      "description": "Group tabs by domain"
    }
  }
}
</code></pre>
<p>Then listen for the command in <code>background.ts</code>:</p>
<pre><code class="language-typescript">chrome.commands.onCommand.addListener((command) =&gt; {
  if (command === "group-tabs") {
    groupTabsByDomain()
  }
})
</code></pre>
<h3 id="heading-3-category-based-grouping">3. Category-Based Grouping</h3>
<p>Rather than grouping by raw domain, you could group by category — putting GitHub, Stack Overflow, and npm together in a "Dev" group, for instance:</p>
<pre><code class="language-typescript">const categories = {
  social: ["facebook.com", "twitter.com", "instagram.com"],
  shopping: ["amazon.com", "ebay.com", "etsy.com"],
  dev: ["github.com", "stackoverflow.com", "npmjs.com"]
}

function getCategoryForDomain(domain: string): string {
  for (const [category, domains] of Object.entries(categories)) {
    if (domains.includes(domain)) {
      return category
    }
  }
  return "other"
}
</code></pre>
<h3 id="heading-4-options-page">4. Options Page</h3>
<p>Plasmo makes it trivial to add a settings page by creating an <code>options.tsx</code> file.</p>
<p>This is where you'd let users toggle auto-grouping, choose between domain and category mode, or configure their own category mappings.</p>
<p>It's a good introduction to the Chrome Storage API and persisting user preferences.</p>
<pre><code class="language-tsx">function OptionsPage() {
  return (
    &lt;div&gt;
      &lt;h1&gt;Tab Grouper Settings&lt;/h1&gt;
      &lt;label&gt;
        &lt;input type="checkbox" /&gt;
        Enable auto-grouping
      &lt;/label&gt;
      &lt;label&gt;
        &lt;input type="checkbox" /&gt;
        Group by category instead of domain
      &lt;/label&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h3 id="heading-5-tab-age-tracking">5. Tab Age Tracking</h3>
<p>You could track when each tab was created and surface tabs that have been sitting untouched for a week or more, a nice way to encourage tab hygiene:</p>
<pre><code class="language-typescript">// Track tab creation times
const tabCreationTimes = new Map&lt;number, number&gt;()

chrome.tabs.onCreated.addListener((tab) =&gt; {
  if (tab.id) {
    tabCreationTimes.set(tab.id, Date.now())
  }
})

// Find old tabs (e.g., &gt; 7 days)
function getOldTabs(): chrome.tabs.Tab[] {
  const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000)
  return tabs.filter(tab =&gt; {
    const created = tabCreationTimes.get(tab.id!)
    return created &amp;&amp; created &lt; sevenDaysAgo
  })
}
</code></pre>
<h3 id="heading-6-search-within-groups">6. Search Within Groups</h3>
<p>A search bar in the popup would let users filter their open tabs by title, making it easy to jump to a specific tab:</p>
<pre><code class="language-tsx">const [searchQuery, setSearchQuery] = useState("")

const filteredTabs = tabs.filter(tab =&gt;
  tab.title?.toLowerCase().includes(searchQuery.toLowerCase())
)
</code></pre>
<h3 id="heading-7-exportimport-groups">7. Export/Import Groups</h3>
<p>You could let users save their current tab groups to a JSON file and restore them later. Useful for preserving a working session across restarts:</p>
<pre><code class="language-typescript">// Export
async function exportGroups() {
  const groups = await chrome.tabGroups.query({})
  const data = JSON.stringify(groups)
  const blob = new Blob([data], { type: 'application/json' })
  const url = URL.createObjectURL(blob)
  chrome.downloads.download({ url, filename: 'tab-groups.json' })
}

// Import
async function importGroups(file: File) {
  const text = await file.text()
  const groups = JSON.parse(text)
  // Restore groups...
}
</code></pre>
<h3 id="heading-8-group-statistics-dashboard">8. Group Statistics Dashboard</h3>
<p>An expanded popup could show browsing analytics, total tabs opened today, most-visited domain, and more:</p>
<pre><code class="language-tsx">function Statistics() {
  const [stats, setStats] = useState({
    totalTabs: 0,
    totalGroups: 0,
    mostUsedDomain: "",
    tabsToday: 0
  })

  return (
    &lt;div&gt;
      &lt;h3&gt;Browsing Statistics&lt;/h3&gt;
      &lt;p&gt;Total tabs opened today: {stats.tabsToday}&lt;/p&gt;
      &lt;p&gt;Most visited domain: {stats.mostUsedDomain}&lt;/p&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h2 id="heading-learning-resources">Learning Resources</h2>
<p>If you want to go deeper, the <a href="https://developer.chrome.com/docs/extensions/">official Chrome Extension docs</a> are excellent and cover every API in detail.</p>
<p>The <a href="https://github.com/GoogleChrome/chrome-extensions-samples">Chrome Extension Samples repository</a> on GitHub has dozens of real examples to learn from. For Plasmo-specific questions, the <a href="https://docs.plasmo.com/">Plasmo documentation</a> and <a href="https://github.com/PlasmoHQ/examples">example repository</a> are the best starting points, and the community is active on <a href="https://www.plasmo.com/community">Plasmo Discord</a>.</p>
<p>The <a href="https://react.dev/">React docs</a> and <a href="https://www.typescriptlang.org/docs/">TypeScript docs</a> are worth bookmarking as reference material, and the <a href="https://react-typescript-cheatsheet.netlify.app/">React TypeScript Cheatsheet</a> is handy when you're unsure about specific type patterns.</p>
<p>For community support, Stack Overflow's <code>chrome-extension</code> tag is well-monitored, and r/chrome_extensions on Reddit is a friendly place to ask questions.</p>
<h2 id="heading-deploying-to-chrome-web-store">Deploying to Chrome Web Store</h2>
<p>Now that you've built and tested your extension, here's how to publish it and share it with the world.</p>
<h3 id="heading-what-youll-need">What You'll Need</h3>
<p>Before you can publish, you'll need a completed and tested extension, a Google account, a $5 USD one-time developer registration fee, and some store assets such as icons, screenshots, and a written description.</p>
<p>The $5 fee is a one-time charge (not annual) that Google uses to verify developer identity and reduce spam. It covers unlimited extension submissions and is processed immediately via Google Payments.</p>
<h3 id="heading-step-1-create-a-production-build">Step 1: Create a Production Build</h3>
<p>Build your extension for production if you didn't do this before:</p>
<pre><code class="language-bash">cd tab-grouper-tutorial
npm run build
</code></pre>
<p>This creates an optimized version in <code>build/chrome-mv3-prod/</code>. The production build minifies JavaScript and CSS for a smaller file size, strips out development-only code and console logs, and optimizes assets for faster loading.</p>
<p>Before uploading, load <code>build/chrome-mv3-prod/</code> as an unpacked extension and test all features one more time to confirm nothing broke in the build process.</p>
<h3 id="heading-step-2-create-store-assets">Step 2: Create Store Assets</h3>
<h4 id="heading-extension-icons">Extension Icons</h4>
<p>You'll need icons in three sizes: <strong>128×128 pixels</strong> for the main store listing (required), <strong>48×48</strong> for the extension management page, and <strong>16×16</strong> for use as a favicon.</p>
<p>All should be PNG files with transparent backgrounds. Keep the design simple and recognizable at small sizes. Avoid putting text in the 16×16 version.</p>
<p><a href="https://figma.com">Figma</a> is free and works well for this, as does <a href="https://canva.com">Canva</a> or <a href="https://gimp.org">GIMP</a>.</p>
<h4 id="heading-screenshots">Screenshots</h4>
<p>Upload between 1 and 5 screenshots at either 1280×800 or 640×400 pixels (PNG or JPEG).</p>
<p>Show the extension in actual use rather than mockups. The popup with statistics, tabs being grouped, and the before/after state all work well.</p>
<p>Adding annotations to highlight key features helps users understand what they're looking at.</p>
<h4 id="heading-promotional-images-optional">Promotional Images (Optional)</h4>
<p>If you want to be featured on the store, you can also upload a small tile (440×280), large tile (920×680), and marquee image (1400×560). These are only needed if Google chooses to promote your extension.</p>
<h4 id="heading-demo-video-optional">Demo Video (Optional)</h4>
<p>A short YouTube video (30–60 seconds) showing the extension in action can significantly increase conversions. Link to it in your store listing.</p>
<h3 id="heading-step-3-write-your-store-listing">Step 3: Write Your Store Listing</h3>
<p><strong>Extension Name</strong> (45 character limit): Be clear and descriptive. "Tab Grouper - Organize Tabs by Domain" works well. Avoid keyword stuffing or excessive punctuation.</p>
<p><strong>Summary</strong> (132 character limit): This is what appears in search results. Lead with what the extension does: "Automatically organize browser tabs by domain. One-click grouping keeps your workspace clean and productive."</p>
<p><strong>Detailed Description</strong> (16,000 character limit): Start with what the extension does, list features clearly, explain how to use it, address privacy, and provide contact information. Here's a template you can adapt:</p>
<pre><code class="language-markdown">## What is Tab Grouper?

Tab Grouper automatically organizes your browser tabs by grouping them based on their website domain. No more hunting through dozens of tabs - everything is neatly organized.

## Features

- ✅ One-click tab grouping
- ✅ Automatic color-coding by domain
- ✅ Real-time statistics
- ✅ Works with all websites
- ✅ Lightweight and fast

## How to Use

1. Click the Tab Grouper icon in your toolbar
2. Click "Group Tabs by Domain"
3. Your tabs are instantly organized

## Why You Need This

If you regularly have numerous tabs open, finding the right one can waste valuable time. Tab Grouper solves this by automatically organizing tabs into colored groups, making navigation quick and straightforward.

## Privacy

This extension does not collect any personal data. It only accesses tab information locally to perform grouping. No data is sent to external servers.

## Support

Found a bug or have a suggestion? Contact us at support@example.com
</code></pre>
<p><strong>Category</strong>: Choose <strong>Productivity</strong> for Tab Grouper. You can add additional languages later if you want to localize the listing.</p>
<h3 id="heading-step-4-register-as-a-chrome-web-store-developer">Step 4: Register as a Chrome Web Store Developer</h3>
<p>Go to the <a href="https://chrome.google.com/webstore/devconsole">Chrome Web Store Developer Dashboard</a>, sign in with your Google account, accept the Developer Agreement, and pay the $5 registration fee. Your account is activated within minutes.</p>
<h3 id="heading-step-5-submit-your-extension">Step 5: Submit Your Extension</h3>
<p>In the Developer Dashboard, click <strong>"New Item"</strong> and upload your extension. You can either manually zip the <code>build/chrome-mv3-prod/</code> folder or use Plasmo's package command:</p>
<pre><code class="language-bash"># Option 1: Manual zip
cd build/chrome-mv3-prod
zip -r ../../tab-grouper.zip .

# Option 2: Use Plasmo package command
cd tab-grouper-tutorial
npm run package
</code></pre>
<p>Once uploaded, fill in all four sections of the store listing form: <strong>Product details</strong> (name, summary, description, category, language), <strong>Graphic assets</strong> (icon and screenshots), <strong>Privacy practices</strong> (see below), and <strong>Distribution</strong> (visibility, regions, pricing).</p>
<h4 id="heading-single-purpose-description">Single Purpose Description</h4>
<p>Chrome requires each extension to have a single, clearly stated purpose. For Tab Grouper: "This extension organizes browser tabs by grouping them based on their domain name, helping users manage multiple open tabs efficiently."</p>
<h4 id="heading-permission-justification">Permission Justification</h4>
<p>You'll need to justify each permission you declared. For <code>tabs</code>: "The tabs permission is required to read tab URLs and titles in order to group them by domain." For <code>tabGroups</code>: "The tabGroups permission is required to create and manage tab groups for organization."</p>
<h4 id="heading-privacy-policy">Privacy Policy</h4>
<p>Even though Tab Grouper doesn't collect personal data, Chrome may require a privacy policy. Host one on GitHub Pages or your personal website and link to it. Here's a minimal template:</p>
<pre><code class="language-markdown"># Privacy Policy for Tab Grouper

## Data Collection
Tab Grouper does not collect, store, or transmit any personal data.

## Permissions
- **tabs**: Used only to read tab URLs for grouping purposes
- **tabGroups**: Used only to create and manage tab groups

## Local Processing
All tab grouping happens locally in your browser. No data is sent to external servers.

## Contact
For questions: your-email@example.com

Last updated: [Current Date]
</code></pre>
<h3 id="heading-step-6-submit-for-review">Step 6: Submit for Review</h3>
<p>Before clicking submit, run through this checklist:</p>
<ul>
<li><p>Production build tested thoroughly</p>
</li>
<li><p>All store assets uploaded (icon + at least one screenshot)</p>
</li>
<li><p>Description is clear and accurate</p>
</li>
<li><p>Permissions are justified</p>
</li>
<li><p>Privacy policy is linked</p>
</li>
<li><p>Extension name is descriptive</p>
</li>
</ul>
<p>When you're ready, click <strong>"Submit for review"</strong>, confirm your details, and click <strong>"Publish"</strong>. Your extension enters the review queue.</p>
<h3 id="heading-step-7-the-review-process">Step 7: The Review Process</h3>
<p>Google typically reviews extensions within 1–3 business days for straightforward submissions, though complex extensions or first submissions can take up to a week. Reviewers check that the extension works as described, that permissions are justified, that there's no malicious code, and that the listing complies with Chrome Web Store policies.</p>
<p>You can track your status in the Developer Dashboard: Pending review → In review → Approved or Rejected. If rejected, Google will email you specific reasons and instructions for resubmitting.</p>
<p>The most common rejection reasons are insufficient permission justification, misleading descriptions, missing privacy policies, and requesting more permissions than necessary. Address each point in the rejection email, update your submission, and resubmit.</p>
<h3 id="heading-step-8-after-approval">Step 8: After Approval</h3>
<p>Once approved, your extension is live at <code>https://chrome.google.com/webstore/detail/[extension-id]</code>. Share the link on social media, write a blog post, post to Reddit (r/chrome, r/chrome_extensions), or submit to Product Hunt to drive installs.</p>
<p>The Developer Dashboard gives you ongoing analytics — total and weekly installs, reviews and ratings, impressions, and uninstall counts. Check it regularly, especially in the first week. Respond to reviews (particularly negative ones), thank users for positive feedback, and use reported bugs to prioritize future updates.</p>
<h3 id="heading-step-9-publishing-updates">Step 9: Publishing Updates</h3>
<p>When you fix bugs or add features, bump the version number in <code>package.json</code> (following <a href="https://semver.org/">Semantic Versioning</a> — patch for bug fixes, minor for new features, major for breaking changes), run <code>npm run build</code>, and upload the new package through the Developer Dashboard's <strong>Package</strong> tab. Updates are typically reviewed faster than initial submissions, often within 24 hours.</p>
<h3 id="heading-step-10-managing-your-extension-long-term">Step 10: Managing Your Extension Long-Term</h3>
<p>The Chrome Web Store provides built-in analytics, but you can also add Google Analytics if you need more detail.</p>
<p>For user support, an email address in the description or a GitHub issues page both work well. As you add features, keep the description updated and maintain a changelog so users know what changed and when. Responding to user questions and reviews goes a long way toward building a loyal base of users who'll recommend the extension to others.</p>
<h3 id="heading-troubleshooting-common-publishing-issues">Troubleshooting Common Publishing Issues</h3>
<p><strong>"Package is invalid" on upload</strong>: Make sure you zipped the contents of <code>build/chrome-mv3-prod/</code> rather than the folder itself, and verify the generated <code>manifest.json</code> is valid JSON.</p>
<p><strong>Rejection: Permissions Not Justified</strong>: In the "Permission justification" field, be specific about which feature requires each permission and what would break without it.</p>
<p><strong>Rejection: Single Purpose Unclear</strong>: Rewrite the single purpose description to focus on one main function, stated plainly.</p>
<p><strong>Low installation rate after launch</strong>: Poor screenshots are often the culprit — they're the first thing most users look at. Make sure they clearly show the extension solving a real problem. Building even a small number of early reviews also makes a big difference to new visitors.</p>
<h3 id="heading-alternative-distribution">Alternative Distribution</h3>
<p>The Chrome Web Store is the right choice for most public extensions. If you're building an internal tool, an <strong>Unlisted</strong> extension (accessible only via direct link, not searchable) is a good option.</p>
<p>If you need to restrict it to users in a specific Google Workspace organization, a <strong>Private</strong> extension is available for that. Self-hosting and sideloading is possible but requires users to enable Developer Mode manually, so it's only practical for very technical audiences.</p>
<h2 id="heading-congratulations">Congratulations!</h2>
<p>You've gone from an empty folder to a live Chrome extension on the Web Store. Along the way you learned how extensions are structured, how background scripts and popups communicate, how Chrome's tab APIs work, and how to navigate the publishing process end to end.</p>
<p>More than any specific API or configuration detail, the most important thing you've built is a mental model for how extensions work and that transfers directly to any extension idea you want to build next.</p>
<p>Keep building, keep learning, and keep shipping!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Chrome Extension That Analyzes Any Web Page Using JavaScript and Manifest V3 ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever visited a website and wondered how well is this page structured? Does it have a meta description? How many links or headings does it use? Usually, you’d open DevTools or an SEO auditing tool to find answers to these questions. But what ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-chrome-extension-using-javascript-and-manifest-v3/</link>
                <guid isPermaLink="false">6900f612596c2221585d95a4</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai seo ]]>
                    </category>
                
                    <category>
                        <![CDATA[ devtools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hitesh Chauhan ]]>
                </dc:creator>
                <pubDate>Tue, 28 Oct 2025 16:57:54 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761670419000/13ac96ca-6e28-413f-a0e0-56ed353a007c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever visited a website and wondered how well is this page structured? Does it have a meta description? How many links or headings does it use?</p>
<p>Usually, you’d open DevTools or an SEO auditing tool to find answers to these questions. But what if you could analyze any web page instantly, without leaving your browser?</p>
<p>In this tutorial, you’ll learn how to build a Chrome extension that scans and analyzes any webpage for titles, meta descriptions, headings, and links.</p>
<p>By the end of this article, you’ll:</p>
<ul>
<li><p>Understand how Manifest V3 works in Chrome Extensions</p>
</li>
<li><p>Learn how to inject content scripts into web pages</p>
</li>
<li><p>Build a popup UI that fetches and displays structured data</p>
</li>
<li><p>Explore how this same foundation can be extended with AI-powered insights</p>
</li>
</ul>
<p>💡 This guide focuses on learning and education – no frameworks or build tools required. Just HTML, CSS, and vanilla JavaScript.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-understanding-how-chrome-extensions-work">Step 1: Understanding How Chrome Extensions Work</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-set-up-the-project-structure">Step 2: Set Up the Project Structure</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-define-the-manifest-file">Step 3: Define the Manifest File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-create-the-popup-ui">Step 4: Create the Popup UI</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-write-the-content-script-contentjs">Step 5: Write the Content Script (content.js)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-6-connect-the-popup-and-content-script">Step 6: Connect the Popup and Content Script</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-7-load-and-test-your-extension">Step 7: Load and Test Your Extension</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-8-add-optional-enhancements">Step 8: Add Optional Enhancements</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-9-publish-to-the-chrome-web-store">Step 9: Publish to the Chrome Web Store</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">🧰 Prerequisites</h2>
<p>Before starting this tutorial, make sure you have:</p>
<ul>
<li><p>A basic understanding of HTML, CSS, and JavaScript</p>
</li>
<li><p>A recent version of Google Chrome installed on your system</p>
</li>
<li><p>Familiarity with using Chrome DevTools (optional but helpful)</p>
</li>
<li><p>A code editor like VS Code or Sublime Text</p>
</li>
<li><p>A local folder where you can create and organize your extension files</p>
</li>
</ul>
<p>💡 Again, no frameworks or build tools are required. We’ll use only vanilla JavaScript and simple web technologies throughout this guide.</p>
<h2 id="heading-step-1-understanding-how-chrome-extensions-work">🧩 Step 1: Understanding How Chrome Extensions Work</h2>
<p>A Chrome extension is just a bundle of web technologies – HTML, CSS, and JS – that extends browser functionality.</p>
<p>Extensions can have multiple parts:</p>
<ul>
<li><p><strong>Manifest file</strong> (<code>manifest.json</code>): defines permissions, icons, and structure.</p>
</li>
<li><p><strong>Content scripts</strong>: run inside web pages and access the DOM.</p>
</li>
<li><p><strong>Background scripts</strong>: handle long-running or event-driven logic.</p>
</li>
<li><p><strong>Popup UI</strong>: what users see when they click your extension icon.</p>
</li>
</ul>
<p>Here’s a high-level flow of what we’ll build:</p>
<pre><code class="lang-plaintext">[Popup UI] &lt;—&gt; [Content Script] &lt;—&gt; [Web Page DOM]
</code></pre>
<p>When the user clicks “Analyze,” the popup will send a message to the content script. The script will then read the DOM and send back results like page title, description, headings, and links.</p>
<h2 id="heading-step-2-set-up-the-project-structure">🧠 Step 2: Set Up the Project Structure</h2>
<p>Create a new folder called <code>page-analyzer-extension</code>. Inside it, create these files:</p>
<pre><code class="lang-plaintext">page-analyzer-extension/
│
├── manifest.json
├── popup.html
├── popup.js
├── content.js
├── styles.css
└── icons/
    ├── icon16.png
    ├── icon48.png
    └── icon128.png
</code></pre>
<p>Icons are optional, but they make the extension look professional. You can use placeholders or generate them from <a target="_blank" href="https://favicon.io/">favicon.io</a>.</p>
<h2 id="heading-step-3-define-the-manifest-file">⚙️ Step 3: Define the Manifest File</h2>
<p>Create <code>manifest.json</code> and paste this in:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"manifest_version"</span>: <span class="hljs-number">3</span>,
  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"Page Analyzer"</span>,
  <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0"</span>,
  <span class="hljs-attr">"description"</span>: <span class="hljs-string">"Analyze any web page for its title, description, headings, and links."</span>,
  <span class="hljs-attr">"permissions"</span>: [<span class="hljs-string">"activeTab"</span>, <span class="hljs-string">"scripting"</span>],
  <span class="hljs-attr">"action"</span>: {
    <span class="hljs-attr">"default_popup"</span>: <span class="hljs-string">"popup.html"</span>,
    <span class="hljs-attr">"default_icon"</span>: {
      <span class="hljs-attr">"16"</span>: <span class="hljs-string">"icons/icon16.png"</span>,
      <span class="hljs-attr">"48"</span>: <span class="hljs-string">"icons/icon48.png"</span>,
      <span class="hljs-attr">"128"</span>: <span class="hljs-string">"icons/icon128.png"</span>
    }
  },
  <span class="hljs-attr">"content_scripts"</span>: [
    {
      <span class="hljs-attr">"matches"</span>: [<span class="hljs-string">"&lt;all_urls&gt;"</span>],
      <span class="hljs-attr">"js"</span>: [<span class="hljs-string">"content.js"</span>]
    }
  ]
}
</code></pre>
<p>Let’s break this down:</p>
<ul>
<li><p><code>manifest_version: 3</code>: the latest version with security and performance improvements</p>
</li>
<li><p><code>permissions</code>: allow the extension to access the active tab and run scripts</p>
</li>
<li><p><code>content_scripts</code>: define which JS files should automatically run in web pages</p>
</li>
</ul>
<h2 id="heading-step-4-create-the-popup-ui">🧩 Step 4: Create the Popup UI</h2>
<p>The popup appears when users click the extension icon.</p>
<p><code>popup.html</code>:</p>
<pre><code class="lang-html"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>Page Analyzer<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">link</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">"stylesheet"</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"styles.css"</span> /&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">h2</span>&gt;</span>Page Analyzer<span class="hljs-tag">&lt;/<span class="hljs-name">h2</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Click below to analyze the current page:<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"analyze"</span>&gt;</span>Analyze Page<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"results"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

    <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"popup.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p><code>styles.css</code>:</p>
<pre><code class="lang-css"><span class="hljs-selector-tag">body</span> {
  <span class="hljs-attribute">font-family</span>: system-ui, sans-serif;
  <span class="hljs-attribute">padding</span>: <span class="hljs-number">12px</span>;
  <span class="hljs-attribute">width</span>: <span class="hljs-number">280px</span>;
}
<span class="hljs-selector-tag">button</span> {
  <span class="hljs-attribute">background</span>: <span class="hljs-number">#2563eb</span>;
  <span class="hljs-attribute">color</span>: white;
  <span class="hljs-attribute">border</span>: none;
  <span class="hljs-attribute">padding</span>: <span class="hljs-number">8px</span> <span class="hljs-number">14px</span>;
  <span class="hljs-attribute">border-radius</span>: <span class="hljs-number">6px</span>;
  <span class="hljs-attribute">cursor</span>: pointer;
  <span class="hljs-attribute">font-weight</span>: <span class="hljs-number">500</span>;
}
<span class="hljs-selector-id">#results</span> {
  <span class="hljs-attribute">margin-top</span>: <span class="hljs-number">12px</span>;
  <span class="hljs-attribute">font-size</span>: <span class="hljs-number">13px</span>;
  <span class="hljs-attribute">line-height</span>: <span class="hljs-number">1.4</span>;
  <span class="hljs-attribute">word-wrap</span>: break-word;
}
</code></pre>
<h2 id="heading-step-5-write-the-content-script-contentjs">🧠 Step 5: Write the Content Script (<code>content.js</code>)</h2>
<p>This script will analyze the web page.</p>
<pre><code class="lang-js"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">analyzePage</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> title = <span class="hljs-built_in">document</span>.title || <span class="hljs-string">"No title found"</span>;
  <span class="hljs-keyword">const</span> description =
    <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'meta[name="description"]'</span>)?.content || <span class="hljs-string">"No description found"</span>;
  <span class="hljs-keyword">const</span> headings = <span class="hljs-built_in">Array</span>.from(<span class="hljs-built_in">document</span>.querySelectorAll(<span class="hljs-string">"h1, h2, h3"</span>)).map(<span class="hljs-function">(<span class="hljs-params">h</span>) =&gt;</span>
    h.innerText.trim()
  );
  <span class="hljs-keyword">const</span> links = <span class="hljs-built_in">document</span>.querySelectorAll(<span class="hljs-string">"a"</span>).length;

  <span class="hljs-keyword">return</span> {
    title,
    description,
    headings,
    <span class="hljs-attr">linkCount</span>: links,
    <span class="hljs-attr">domain</span>: location.hostname,
  };
}

chrome.runtime.onMessage.addListener(<span class="hljs-function">(<span class="hljs-params">message, sender, sendResponse</span>) =&gt;</span> {
  <span class="hljs-keyword">if</span> (message.action === <span class="hljs-string">"analyze"</span>) {
    sendResponse(analyzePage());
  }
});
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p>We extract the title, description, headings, and total link count</p>
</li>
<li><p>We return this data as a structured object</p>
</li>
<li><p>The script listens for messages from the popup and responds with the analysis</p>
</li>
</ul>
<h2 id="heading-step-6-connect-the-popup-and-content-script">⚡ Step 6: Connect the Popup and Content Script</h2>
<p>In <code>popup.js</code>, add the logic that triggers page analysis.</p>
<pre><code class="lang-js"><span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"analyze"</span>).addEventListener(<span class="hljs-string">"click"</span>, <span class="hljs-keyword">async</span> () =&gt; {
  <span class="hljs-keyword">const</span> [tab] = <span class="hljs-keyword">await</span> chrome.tabs.query({ <span class="hljs-attr">active</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">currentWindow</span>: <span class="hljs-literal">true</span> });

  chrome.tabs.sendMessage(tab.id, { <span class="hljs-attr">action</span>: <span class="hljs-string">"analyze"</span> }, <span class="hljs-function">(<span class="hljs-params">response</span>) =&gt;</span> {
    <span class="hljs-keyword">const</span> resultContainer = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"results"</span>);

    <span class="hljs-keyword">if</span> (!response) {
      resultContainer.innerText = <span class="hljs-string">"Unable to analyze this page."</span>;
      <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">const</span> { title, description, headings, linkCount, domain } = response;
    resultContainer.innerHTML = <span class="hljs-string">`
      &lt;strong&gt;Domain:&lt;/strong&gt; <span class="hljs-subst">${domain}</span>&lt;br/&gt;
      &lt;strong&gt;Title:&lt;/strong&gt; <span class="hljs-subst">${title}</span>&lt;br/&gt;
      &lt;strong&gt;Description:&lt;/strong&gt; <span class="hljs-subst">${description}</span>&lt;br/&gt;
      &lt;strong&gt;Headings:&lt;/strong&gt; <span class="hljs-subst">${
        headings.length ? headings.join(<span class="hljs-string">", "</span>) : <span class="hljs-string">"No headings found"</span>
      }</span>&lt;br/&gt;
      &lt;strong&gt;Links:&lt;/strong&gt; <span class="hljs-subst">${linkCount}</span>
    `</span>;
  });
});
</code></pre>
<p>This uses the <strong>Chrome Tabs API</strong> to find the current tab and send a message to the content script. When the script responds, we update the popup with the results.</p>
<h2 id="heading-step-7-load-and-test-your-extension">🧪 Step 7: Load and Test Your Extension</h2>
<ol>
<li><p>Open chrome://extensions/</p>
</li>
<li><p>Enable Developer Mode</p>
</li>
<li><p>Click Load Unpacked</p>
</li>
<li><p>Select your project folder</p>
</li>
</ol>
<p>Now, pin your extension to the toolbar, open any website, and click “Analyze Page.”</p>
<p>You’ll instantly see:</p>
<ul>
<li><p>The page’s title</p>
</li>
<li><p>Meta description</p>
</li>
<li><p>Extracted headings (H1–H3)</p>
</li>
<li><p>Link count</p>
</li>
<li><p>Domain name</p>
</li>
</ul>
<p>🎉 Congratulations! You’ve built a working web page analyzer.</p>
<h2 id="heading-step-8-add-optional-enhancements">🧩 Step 8: Add Optional Enhancements</h2>
<p>Now that the basics work, here are some ways to level up your project.</p>
<h3 id="heading-1-add-ai-insights">🧠 1. Add AI Insights</h3>
<p>You can connect to an AI API (like OpenAI or Gemini) to summarize the page or evaluate SEO structure.</p>
<pre><code class="lang-js"><span class="hljs-comment">// Example: pseudo-code for calling an AI API</span>
<span class="hljs-keyword">const</span> aiResponse = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"https://api.openai.com/v1/chat/completions"</span>, {
  <span class="hljs-attr">method</span>: <span class="hljs-string">"POST"</span>,
  <span class="hljs-attr">headers</span>: { <span class="hljs-attr">Authorization</span>: <span class="hljs-string">`Bearer <span class="hljs-subst">${API_KEY}</span>`</span> },
  <span class="hljs-attr">body</span>: <span class="hljs-built_in">JSON</span>.stringify({
    <span class="hljs-attr">model</span>: <span class="hljs-string">"gpt-4o-mini"</span>,
    <span class="hljs-attr">messages</span>: [
      { <span class="hljs-attr">role</span>: <span class="hljs-string">"system"</span>, <span class="hljs-attr">content</span>: <span class="hljs-string">"You are an SEO assistant."</span> },
      { <span class="hljs-attr">role</span>: <span class="hljs-string">"user"</span>, <span class="hljs-attr">content</span>: <span class="hljs-string">`Analyze the following page info: <span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(pageData)}</span>`</span> }
    ]
  })
});
</code></pre>
<p>For example, after building this basic analyzer, I expanded it into a full-featured <a target="_blank" href="https://rankingsfactor.com/extension">RankingsFactor AI SEO Extension</a> which combines this same foundation with:</p>
<ul>
<li><p>AI-generated keyword suggestions</p>
</li>
<li><p>Metadata improvement recommendations</p>
</li>
<li><p>Automatic screenshot capture</p>
</li>
<li><p>Page freshness detection</p>
</li>
</ul>
<p>This demonstrates how a simple developer project can evolve into a powerful, production-ready tool.</p>
<h3 id="heading-2-detect-missing-seo-tags">🔍 2. Detect Missing SEO Tags</h3>
<p>You can check for missing tags like this:</p>
<pre><code class="lang-js"><span class="hljs-keyword">const</span> missingTags = [];
<span class="hljs-keyword">if</span> (!<span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'meta[name="description"]'</span>)) missingTags.push(<span class="hljs-string">"description"</span>);
<span class="hljs-keyword">if</span> (!<span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'meta[property="og:title"]'</span>)) missingTags.push(<span class="hljs-string">"og:title"</span>);
</code></pre>
<h3 id="heading-3-add-screenshot-or-report-export">🖼️ 3. Add Screenshot or Report Export</h3>
<p>Use the <code>chrome.tabs.captureVisibleTab()</code> API to take a screenshot, or generate a downloadable HTML/JSON report.</p>
<h2 id="heading-step-9-publish-to-the-chrome-web-store">🧭 Step 9: Publish to the Chrome Web Store</h2>
<p>Once you’ve tested your extension, visit <a target="_blank" href="https://chrome.google.com/webstore/devconsole">chrome.google.com/webstore/devconsole</a>. You’ll need to pay a one-time $5 developer registration fee, then you can upload your extension as a ZIP file. Make sure you write a clear, helpful description before submitting your extension for review.</p>
<h2 id="heading-final-thoughts">✅ Final Thoughts</h2>
<p>In this tutorial, you learned:</p>
<ul>
<li><p>How Chrome extensions communicate between scripts and web pages</p>
</li>
<li><p>How to safely extract DOM data</p>
</li>
<li><p>How to display structured information in a popup UI</p>
</li>
<li><p>How to extend browser tools with AI for smarter analysis</p>
</li>
</ul>
<p>Browser extensions are an incredible way to bring web automation, analysis, and creativity directly into your workflow. Whether you’re analyzing pages, improving accessibility, or experimenting with AI, you now have the foundation to build anything you imagine.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Advice Generator Chrome Extension with Manifest V3 ]]>
                </title>
                <description>
                    <![CDATA[ In 2025, using Chrome without extensions is like using a smartphone without apps. It’s possible, but you’re missing out on a lot. And despite how essential extensions are, creating one is very simple – it’s just HTML, CSS, and JavaScript with browser... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-advice-generator-chrome-extension-with-manifest-v3/</link>
                <guid isPermaLink="false">68acc54701889f3eebbb5f25</guid>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ophy Boamah ]]>
                </dc:creator>
                <pubDate>Mon, 25 Aug 2025 20:19:19 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1756152220137/8717b809-1186-4a15-92f8-ea926177ef76.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In 2025, using Chrome without extensions is like using a smartphone without apps. It’s possible, but you’re missing out on a lot.</p>
<p>And despite how essential extensions are, creating one is very simple – it’s just HTML, CSS, and JavaScript with browser APIs.</p>
<p>In this tutorial, we are going to learn about Chrome extensions by building an Advice Generator extension with Manifest V3 (MV3), the latest and most secure architecture for Chrome Extensions. You can move along to see what we'll build <a class="post-section-overview" href="#heading-what-were-going-to-build">here</a>.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-are-the-key-components-of-a-chrome-extension">What are the Key Components of a Chrome Extension?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-an-advice-generator-chrome-extension">How to Build an Advice Generator Chrome Extension</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-the-benefits-of-manifest-v3">The Benefits of Manifest V3</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-debug-your-chrome-extension">How to Debug Your Chrome Extension</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-are-the-key-components-of-a-chrome-extension">What are the Key Components of a Chrome Extension?</h2>
<p>Chrome extensions are incredibly powerful tools that can add custom functionality directly into your Browser experience to transform how you use the web.</p>
<p>Before we write any code, let's understand some key components:</p>
<ul>
<li><p>Every extension starts with a <strong>manifest</strong> file. This JSON file tells Chrome everything it needs to know about an extension: name, version, permissions, and files</p>
</li>
<li><p>The <strong>user interface</strong> is built with HTML, CSS, and JavaScript. It's essentially a mini webpage that lives inside your browser</p>
</li>
<li><p>Finally, there's the <strong>service worker</strong> which runs in the background and fetches data from external APIs. In Manifest V3, service workers have replaced background pages</p>
</li>
</ul>
<h2 id="heading-how-to-build-an-advice-generator-chrome-extension">How to Build an Advice Generator Chrome Extension</h2>
<p>Here’s a look at what we’re going to build:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755718930961/381f422d-f83c-49a9-a007-051ddea6e8da.png" alt="381f422d-f83c-49a9-a007-051ddea6e8da" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>This design is by <a target="_blank" href="https://www.frontendmentor.io/?via=ophyboamah">Frontend Mentor</a>.</p>
<p><strong>Prerequisites:</strong></p>
<p>To follow along with this tutorial, you need:</p>
<ul>
<li><p>Basic understanding of HTML, CSS and JavaScript</p>
</li>
<li><p>A Chrome browser</p>
</li>
<li><p>A text editor</p>
</li>
</ul>
<p>When structuring an extension project, the only prerequisite is to place the <code>manifest.json</code> file in the extension's root directory.</p>
<h3 id="heading-testing-your-chrome-extension-load-unpacked"><strong>Testing Your Chrome Extension (Load Unpacked)</strong></h3>
<p>Before we start building, you’ll want to see your progress after each file to catch any issues early. Here’s how to load your extension into Chrome for testing:</p>
<ol>
<li><p>Go to <code>chrome://extensions</code> to open the Chrome Extensions page.</p>
</li>
<li><p>In the top right corner of the Extensions page, toggle the <strong>Developer mode</strong> on.</p>
</li>
<li><p>Click the <strong>Load unpacked</strong> button that appears.</p>
</li>
<li><p>In the file dialog, go to the <strong>root folder of the extension</strong> and click <strong>Select Folder</strong>.</p>
</li>
</ol>
<p>Your extension should appear. If its icon does not appear in your browser's toolbar immediately, click the <strong>puzzle</strong> icon in your toolbar and pin it.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755711497449/6c3f056c-322d-45a2-b1b3-15a62b7fad44.png" alt="A screenshot showing how to pin a Chrome extension from a browser's toolbar" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Now let's start by defining our extension's identity in the <code>manifest.json</code> file.</p>
<h3 id="heading-the-benefits-of-manifest-v3">The Benefits of Manifest V3</h3>
<p>The <code>manifest.json</code> is the heart of a Chrome Extension. Written in JSON (JavaScript Object Notation), it provides Chrome with everything it needs to know about your extension.</p>
<p>Think of it like a passport with visas and Chrome as the immigration officer verifying identity and access.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755715844005/b1742fbf-fafa-4c36-b08c-1637d00d625d.png" alt="An image depicting the analogy of the Manifest file as a passport and visa and Chrome as the immigration officer that ensures the right permissions." class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Manifest V3 (MV3), brings better performance, security, and reliability to extensions. MV3 uses service workers that activate only when needed, improving battery life and preventing extensions from slowing down your browser.</p>
<p>Let's break down each important field:</p>
<ul>
<li><p><code>manifest_version</code> is the most critical line. Give it a value of 3 to tell Chrome you're using Manifest V3.</p>
</li>
<li><p><code>name</code><strong>,</strong> <code>version</code><strong>,</strong> <code>description</code> define your extension's basic identity.</p>
</li>
<li><p><code>action</code> is a Manifest V3 field that controls what happens when someone clicks your extension's <code>default_icon</code> in the toolbar. The <code>default_popup</code> points to your HTML file, so clicking the icon opens that page in a small popup window.</p>
</li>
<li><p><code>permissions</code> tells Chrome what your extension needs access to. We're using host permission <a target="_blank" href="https://api.adviceslip.com/">https://api.adviceslip.com/*</a> so our extension can fetch advice from that API. Without it, the extension would be blocked from making those requests. This might seem overly cautious, but it's a security measure that protects users.</p>
</li>
<li><p><code>background</code> points to your service worker script. The <code>service_worker</code> field tells Chrome that <code>service-worker.js</code> should run in the background.</p>
</li>
</ul>
<h3 id="heading-step-1-create-a-manifest-3-file">Step 1: Create a Manifest 3 File</h3>
<p>Going by the explanations of the various parts of the file above, here's what our <code>manifest.json</code> file would look like:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"Advice Generator"</span>,
  <span class="hljs-attr">"description"</span>: <span class="hljs-string">"Get a fresh piece of advice whenever you need it!"</span>,
  <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0"</span>,
  <span class="hljs-attr">"manifest_version"</span>: <span class="hljs-number">3</span>,
  <span class="hljs-attr">"action"</span>: {
    <span class="hljs-attr">"default_popup"</span>: <span class="hljs-string">"index.html"</span>,
    <span class="hljs-attr">"default_icon"</span>: <span class="hljs-string">"/icons/icon-dice.png"</span>
  },
  <span class="hljs-attr">"permissions"</span>: [
    <span class="hljs-string">"activeTab"</span>
  ],
  <span class="hljs-attr">"host_permissions"</span>: [
    <span class="hljs-string">"https://api.adviceslip.com/*"</span>
  ],
  <span class="hljs-attr">"background"</span>: {
    <span class="hljs-attr">"service_worker"</span>: <span class="hljs-string">"service-worker.js"</span>
  }
}
</code></pre>
<p>You might see <code>activeTab</code> in other extension examples. While we don't strictly need it for this, it's worth knowing about. It gives temporary access to whatever tab the user is on, but only when they click the extensions icon.</p>
<p>The image below will be the result of running our manifest code above:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755711701772/49fa40e5-1c5b-4cd3-a6f2-a9100db8efcb.png" alt="A screenshot showing the Advice Generator Chrome extension after running the Manifest.json" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h3 id="heading-step-2-create-the-html-and-css-pages">Step 2: Create the HTML and CSS Pages</h3>
<p>Now that our extension has its identity and permissions defined, let's move on to building the user interface starting with an <code>index.html</code> page.</p>
<pre><code class="lang-xml"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">"en"</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"UTF-8"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"viewport"</span> <span class="hljs-attr">content</span>=<span class="hljs-string">"width=device-width, initial-scale=1.0"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>Advice Generator<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">link</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">"stylesheet"</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"style.css"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">link</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"https://fonts.googleapis.com/css2?family=Manrope:wght@400;800&amp;display=swap"</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">"stylesheet"</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">main</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"advice-card"</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">h1</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"advice-id"</span>&gt;</span>ADVICE #<span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"advice-id-number"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"advice-quote"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"advice-quote"</span>&gt;</span>
            “It is easy to sit up and take notice, what's difficult is getting up and taking action.”
        <span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"divider"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"icons/pattern-divider.png"</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">"Divider pattern"</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"dice-button"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"generate-advice-btn"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"icons/icon-dice.png"</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">"Dice icon"</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">main</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"index.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p>Now, let's bring the design to life with a <code>style.css</code> file in your root directory. We'll set up the overall body styles, position the card, and style all the elements within it.</p>
<pre><code class="lang-css"><span class="hljs-selector-pseudo">:root</span> {
    <span class="hljs-comment">/* Define colors from the Frontend Mentor style guide */</span>
    <span class="hljs-attribute">--clr-light-cyan</span>: <span class="hljs-built_in">hsl</span>(<span class="hljs-number">193</span>, <span class="hljs-number">38%</span>, <span class="hljs-number">86%</span>);
    <span class="hljs-attribute">--clr-neon-green</span>: <span class="hljs-built_in">hsl</span>(<span class="hljs-number">150</span>, <span class="hljs-number">100%</span>, <span class="hljs-number">66%</span>);
    <span class="hljs-attribute">--clr-grayish-blue</span>: <span class="hljs-built_in">hsl</span>(<span class="hljs-number">217</span>, <span class="hljs-number">19%</span>, <span class="hljs-number">35%</span>);
    <span class="hljs-attribute">--clr-dark-grayish-blue</span>: <span class="hljs-built_in">hsl</span>(<span class="hljs-number">217</span>, <span class="hljs-number">19%</span>, <span class="hljs-number">25%</span>);
    <span class="hljs-attribute">--clr-dark-blue</span>: <span class="hljs-built_in">hsl</span>(<span class="hljs-number">218</span>, <span class="hljs-number">23%</span>, <span class="hljs-number">16%</span>); 
    <span class="hljs-comment">/* Typography */</span>
    <span class="hljs-attribute">--ff-manrope</span>: <span class="hljs-string">'Manrope'</span>, sans-serif;
    <span class="hljs-attribute">--fw-regular</span>: <span class="hljs-number">400</span>;
    <span class="hljs-attribute">--fw-bold</span>: <span class="hljs-number">700</span>;
}
<span class="hljs-selector-tag">body</span> {
    <span class="hljs-attribute">margin</span>: <span class="hljs-number">0</span>;
    <span class="hljs-attribute">padding</span>: <span class="hljs-number">0</span>;
    <span class="hljs-attribute">font-family</span>: <span class="hljs-built_in">var</span>(--ff-manrope);
    <span class="hljs-attribute">background-color</span>: <span class="hljs-built_in">var</span>(--clr-dark-blue);
    <span class="hljs-attribute">display</span>: flex;
    <span class="hljs-attribute">justify-content</span>: center;
    <span class="hljs-attribute">align-items</span>: center;
    <span class="hljs-attribute">min-height</span>: <span class="hljs-number">100vh</span>;
    <span class="hljs-attribute">min-width</span>: <span class="hljs-number">30rem</span>;
    <span class="hljs-attribute">box-sizing</span>: border-box;
}
<span class="hljs-selector-class">.advice-card</span> {
    <span class="hljs-attribute">background-color</span>: <span class="hljs-built_in">var</span>(--clr-dark-grayish-blue);
    <span class="hljs-attribute">border-radius</span>: <span class="hljs-number">0.5rem</span>;
    <span class="hljs-attribute">padding</span>: <span class="hljs-number">1.5rem</span> <span class="hljs-number">1.5rem</span>;
    <span class="hljs-attribute">width</span>: <span class="hljs-number">60%</span>; 
    <span class="hljs-attribute">text-align</span>: center;
    <span class="hljs-attribute">position</span>: relative;
    <span class="hljs-attribute">box-shadow</span>: <span class="hljs-number">0</span> <span class="hljs-number">5px</span> <span class="hljs-number">20px</span> <span class="hljs-built_in">rgba</span>(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, <span class="hljs-number">0.2</span>);
    <span class="hljs-attribute">margin-bottom</span>: <span class="hljs-number">70px</span>; 
}
<span class="hljs-selector-class">.advice-id</span> {
    <span class="hljs-attribute">color</span>: <span class="hljs-built_in">var</span>(--clr-neon-green);
    <span class="hljs-attribute">font-size</span>: <span class="hljs-number">0.8em</span>;
    <span class="hljs-attribute">letter-spacing</span>: <span class="hljs-number">4px</span>;
    <span class="hljs-attribute">text-transform</span>: uppercase;
    <span class="hljs-attribute">margin-bottom</span>: <span class="hljs-number">20px</span>;
}
<span class="hljs-selector-class">.advice-quote</span> {
    <span class="hljs-attribute">color</span>: <span class="hljs-built_in">var</span>(--clr-light-cyan);
    <span class="hljs-attribute">font-size</span>: <span class="hljs-number">1.75em</span>; 
    <span class="hljs-attribute">font-weight</span>: <span class="hljs-built_in">var</span>(--fw-bold);
    <span class="hljs-attribute">line-height</span>: <span class="hljs-number">1.4</span>;
    <span class="hljs-attribute">margin-bottom</span>: <span class="hljs-number">1.2rem</span>;
    <span class="hljs-attribute">padding</span>: <span class="hljs-number">0</span> <span class="hljs-number">15px</span>;
}
<span class="hljs-selector-class">.divider</span> {
    <span class="hljs-attribute">margin-bottom</span>: <span class="hljs-number">35px</span>;
}
<span class="hljs-selector-class">.divider</span> <span class="hljs-selector-tag">img</span> {
    <span class="hljs-attribute">max-width</span>: <span class="hljs-number">90%</span>;
    <span class="hljs-attribute">height</span>: auto;
}
<span class="hljs-selector-class">.dice-button</span> {
    <span class="hljs-attribute">background-color</span>: <span class="hljs-built_in">var</span>(--clr-neon-green);
    <span class="hljs-attribute">border</span>: none;
    <span class="hljs-attribute">border-radius</span>: <span class="hljs-number">50%</span>;
    <span class="hljs-attribute">width</span>: <span class="hljs-number">2rem</span>;
    <span class="hljs-attribute">height</span>: <span class="hljs-number">2rem</span>;
    <span class="hljs-attribute">display</span>: flex;
    <span class="hljs-attribute">justify-content</span>: center;
    <span class="hljs-attribute">align-items</span>: center;
    <span class="hljs-attribute">cursor</span>: pointer;
    <span class="hljs-attribute">position</span>: absolute;
    <span class="hljs-attribute">bottom</span>: -<span class="hljs-number">1rem</span>; 
    <span class="hljs-attribute">left</span>: <span class="hljs-number">50%</span>;
    <span class="hljs-attribute">padding</span>: <span class="hljs-number">1rem</span>;
    <span class="hljs-attribute">transform</span>: <span class="hljs-built_in">translateX</span>(-<span class="hljs-number">50%</span>);
    <span class="hljs-attribute">transition</span>: box-shadow <span class="hljs-number">0.3s</span> ease-in-out;
}
<span class="hljs-selector-class">.dice-button</span><span class="hljs-selector-pseudo">:hover</span> {
    <span class="hljs-attribute">box-shadow</span>: <span class="hljs-number">0</span> <span class="hljs-number">0</span> <span class="hljs-number">40px</span> <span class="hljs-built_in">var</span>(--clr-neon-green);
}
<span class="hljs-selector-class">.dice-button</span> <span class="hljs-selector-tag">img</span> {
    <span class="hljs-attribute">width</span>: <span class="hljs-number">2rem</span>;
    <span class="hljs-attribute">height</span>: <span class="hljs-number">2rem</span>;
}
</code></pre>
<p>The image below will be the result of running our HTML and CSS code above plus the initial manifest:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755711748813/c641535b-5bdf-4adc-a11c-6115b1f1a284.png" alt="An image of the Advice Generator Chrome extension design" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>With the HTML and CSS done, our extension's visual aspect is complete. Next, let’s give it life by writing the JavaScript that handles fetching new advice and updating the display.</p>
<h3 id="heading-step-3-add-a-service-worker">Step 3: Add a Service Worker</h3>
<p>In Manifest V3, the core background logic for an extension lives in its Service Worker. Unlike the persistent background pages of Manifest V2, in V3 Service Workers run only when needed, such as in response to a message from <code>index.js</code> or a browser event.</p>
<p>Our <code>service-worker.js</code> will have these roles:</p>
<ul>
<li><p>Listen for a request from <code>index.js</code> (when the user clicks the dice).</p>
</li>
<li><p>Fetch a new piece of advice from the Advice Slip API.</p>
</li>
<li><p>Send that advice back to <code>index.js</code> to be displayed.</p>
</li>
</ul>
<p>Create a file named <code>service-worker.js</code> in your extension's root directory.</p>
<pre><code class="lang-javascript">chrome.runtime.onMessage.addListener(<span class="hljs-function">(<span class="hljs-params">request, sender, sendResponse</span>) =&gt;</span> {
  <span class="hljs-keyword">if</span> (request.action === <span class="hljs-string">"fetchAdvice"</span>) {
    fetchAdvice().then(<span class="hljs-function"><span class="hljs-params">adviceData</span> =&gt;</span> {
      sendResponse({ <span class="hljs-attr">advice</span>: adviceData });
    }).catch(<span class="hljs-function"><span class="hljs-params">error</span> =&gt;</span> {
      <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Error fetching advice:"</span>, error);
      sendResponse({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Failed to fetch advice"</span> });
    });
    <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
  }
});
<span class="hljs-comment">// Function to fetch advice from the Advice Slip API</span>
<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">fetchAdvice</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"https://api.adviceslip.com/advice"</span>);
    <span class="hljs-keyword">if</span> (!response.ok) {
      <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">`HTTP error! status: <span class="hljs-subst">${response.status}</span>`</span>);
    }
    <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> response.json();
    <span class="hljs-keyword">return</span> data.slip; 
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Could not fetch advice:"</span>, error);
    <span class="hljs-keyword">throw</span> error; 
  }
}
</code></pre>
<h4 id="heading-message-handling-in-service-workers">Message Handling in Service Workers</h4>
<p>Since Service Workers don't have direct access to the DOM of your <code>index.html</code> page (and vice versa), they communicate using message passing. As you can see in the code above, the user clicks the dice in <code>index.html</code>, and <code>index.js</code> will send a message to <code>service-worker.js</code> asking for new advice. The Service Worker will then fetch the advice and send it back in another message.</p>
<p><code>chrome.runtime.onMessage.addListener</code> listens for incoming messages and <code>sendResponse</code> replies.</p>
<p>Our Service Worker is now ready to fetch advice. The next step is to make our <code>index.js</code> interact with it.</p>
<h3 id="heading-step-4-add-app-functionality">Step 4: Add App Functionality</h3>
<p>First, we'll create our <code>index.js</code> file. This script is responsible for all the user-facing logic. It will handle the user's interaction (clicking the dice), send a message to our <code>service-worker.js</code> to get new advice, and then update the <code>index.html</code> with the fetched advice.</p>
<p>Our <code>index.js</code> will perform the following steps:</p>
<ol>
<li><p>Reference the HTML elements where we'll display the advice ID, quote, and dice.</p>
</li>
<li><p>Set up an event listener for when the dice is clicked.</p>
</li>
<li><p>Send a message to the <code>service-worker.js</code> to request new advice.</p>
</li>
<li><p>Receive the advice back from <code>service-worker.js</code> and update the content on the <code>index.html</code> page.</p>
</li>
</ol>
<pre><code class="lang-javascript"><span class="hljs-comment">// Get references to our HTML elements</span>
<span class="hljs-keyword">const</span> adviceIdElement = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'advice-id-number'</span>);
<span class="hljs-keyword">const</span> adviceQuoteElement = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'advice-quote'</span>);
<span class="hljs-keyword">const</span> generateAdviceBtn = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'generate-advice-btn'</span>);
<span class="hljs-comment">// Function to request advice from the Service Worker</span>
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">requestNewAdvice</span>(<span class="hljs-params"></span>) </span>{
  chrome.runtime.sendMessage({ <span class="hljs-attr">action</span>: <span class="hljs-string">"fetchAdvice"</span> }, <span class="hljs-function">(<span class="hljs-params">response</span>) =&gt;</span> {
    <span class="hljs-keyword">if</span> (chrome.runtime.lastError) {
      <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Error sending message:"</span>, chrome.runtime.lastError);
      adviceQuoteElement.textContent = <span class="hljs-string">"Error: Could not get advice."</span>;
      adviceIdElement.textContent = <span class="hljs-string">"---"</span>;
      <span class="hljs-keyword">return</span>;
    }
    <span class="hljs-keyword">if</span> (response &amp;&amp; response.advice) {
      adviceIdElement.textContent = response.advice.id;
      adviceQuoteElement.textContent = <span class="hljs-string">`“<span class="hljs-subst">${response.advice.advice}</span>”`</span>;
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (response &amp;&amp; response.error) {
      <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Service Worker error:"</span>, response.error);
      adviceQuoteElement.textContent = <span class="hljs-string">`Error: <span class="hljs-subst">${response.error}</span>`</span>;
      adviceIdElement.textContent = <span class="hljs-string">"---"</span>;
    }
  });
}
<span class="hljs-keyword">if</span> (generateAdviceBtn) {
  generateAdviceBtn.addEventListener(<span class="hljs-string">'click'</span>, requestNewAdvice);
} <span class="hljs-keyword">else</span> {
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Generate advice button not found!"</span>);
}
<span class="hljs-built_in">document</span>.addEventListener(<span class="hljs-string">'DOMContentLoaded'</span>, requestNewAdvice);
</code></pre>
<p>With <code>index.js</code> in place, our Advice Generator is now ready as you can see in the GIF below:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755717163786/326dc332-bf3b-4a8a-ba3a-25bdc829cf68.gif" alt="A GIF showing the finished Advice Generator Chrome extension" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>The next crucial step is to know how to debug your extension, should anything go wrong.</p>
<h2 id="heading-how-to-debug-your-chrome-extension">How to Debug Your Chrome Extension</h2>
<p>Chrome provides excellent debugging tools to help troubleshoot extensions. Always follow these essential steps:</p>
<ul>
<li><p>Reload your extension after making changes (especially to <code>manifest.json</code> or <code>service-worker.js</code>) by clicking the refresh icon on <code>chrome://extensions</code>.</p>
</li>
<li><p>Check your <code>manifest.json</code> for typos – missing commas or brackets will break everything.</p>
</li>
<li><p>Verify your API URL and make sure you have the right permissions listed in <code>manifest.json</code>.</p>
</li>
</ul>
<h3 id="heading-debugging-the-main-html-and-js-pages">Debugging the Main HTML and JS Pages</h3>
<p>This is likely where you'll encounter most of your initial JavaScript or HTML/CSS issues.</p>
<ol>
<li><p>Open the extension and right-click anywhere in the popup to Inspect.</p>
</li>
<li><p>Check the Console tab for JavaScript errors from your <code>index.js</code> file.</p>
</li>
<li><p>Use the Elements tab to inspect your HTML and tweak CSS styles in real-time.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755714931805/f03dc46c-b78b-44b6-bc67-bb7fe02499a9.gif" alt="A GIF showing how to inspect the Elements and Console tabs of a Chrome extension" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h3 id="heading-debugging-the-service-worker-crucial-for-mv3"><strong>Debugging the Service Worker – Crucial for MV3</strong></h3>
<p>The Service Worker runs in the background and has its own separate DevTools.</p>
<ol>
<li><p>Go to <code>chrome://extensions</code>.</p>
</li>
<li><p>Click the <strong>Service worker</strong> link underneath your extension or the Errors button.</p>
</li>
<li><p>Check the Console and Network tabs for service worker and API errors respectively.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755715410580/2c03c56c-affe-4a0a-939f-72ca866eecd3.png" alt="A screenshot showing a Chrome extension with the service worker link and errors button" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>Congratulations, you've just built a Chrome extension using Manifest V3. You've created a user interface, implemented background processing with a service worker, and established communication between different parts of your extension. These skills are the building blocks for any Chrome extension, no matter how simple or complex.</p>
<p>Here are some helpful resources:</p>
<ul>
<li><p><a target="_blank" href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/What_are_WebExtensions">MDN on Browser Extensions</a></p>
</li>
<li><p><a target="_blank" href="https://developer.chrome.com/docs/extensions/get-started?hl=en">Chrome Devs on Chrome Extensions</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Chrome Extension ]]>
                </title>
                <description>
                    <![CDATA[ A chrome extension is a software program that is designed to run within the Google Chrome web browser. Extensions can add a variety of functionality to the browser, including providing tools for web development, adding features to the browser interfa... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-chrome-extension/</link>
                <guid isPermaLink="false">66b202e6eea9870582e16c4d</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Fri, 27 May 2022 14:35:37 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2022/05/ext.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A chrome extension is a software program that is designed to run within the Google Chrome web browser.</p>
<p>Extensions can add a variety of functionality to the browser, including providing tools for web development, adding features to the browser interface, and changing the behavior of web pages.</p>
<p>We just published a course on the freeCodeCamp.org YouTube channel that will teach you how to create your own Chrome extension with JavaScript.</p>
<p>By the end of this course you will understand how to create a modern Chrome extension. You will learn how to create the extension using the new iteration of the web extensions platform, called Manifest V3. </p>
<p>To follow along, it is best to have a basic understanding of JavaScript and DOM manipulation.</p>
<p>Raman Hundal created this course. Raman is a great instructor and he works for Pieces.app. Pieces.app provided a grant that made this course possible but you don't need to use their extension to follow along.</p>
<p>The extension you will create in this course is a YouTube bookmarker. It will make it so anytime you navigate to a YouTube video page an icon will appear on the video player to allow you to bookmark a particular timestamp on the video.</p>
<p>Watch the full course below or <a target="_blank" href="https://www.youtube.com/watch?v=0n809nd4Zu4">on the freeCodeCamp.org YouTube channel</a> (1-hour watch).</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/0n809nd4Zu4" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<h3 id="heading-transcript">Transcript</h3>
<p>(autogenerated)</p>
<p>If you want to create your own Chrome extension, you're in the right place.</p>
<p>In this course, Rahman will teach you how to create a Chrome extension using the new iteration of the web extensions platform, which is called Manifest v3.</p>
<p>Rahman is a great instructor.</p>
<p>And he works for pieces that app pieces that app provided a grant that made this course possible, but you don't have to use their extension to follow along share in the comments what type of Chrome extension you want to make.</p>
<p>Now the reason I want to teach about this particular topic is I've created two web extensions in my career.</p>
<p>The first is for a previous company, where my extension generated a significant amount of revenue for the company.</p>
<p>And the second for my current company pieces, where our web extensions play a critical part of our product stack, and help developers across the globe boost their productivity.</p>
<p>I'm going to be using the pieces web extensions and integrations quite a bit in this video.</p>
<p>So if you're interested in downloading an AI coding assistant that helps you save and reuse code snippets, convert screenshots to code, and more, go ahead and download pieces in the description.</p>
<p>And you can totally follow along.</p>
<p>So during my journey as a Chrome extension developer, I did often notice that tutorials and StackOverflow answers were using outdated versions of the web extensions platform.</p>
<p>My hope for you after you leave this video is that you have a resource to create a modern Chrome extension, and you understand the difference between the newer version of the web extensions platform manifest v3, and older versions.</p>
<p>Now, before we get started, there's going to be three prerequisites to this course, the first is required, and it's that you have a basic understanding of JavaScript and DOM manipulation.</p>
<p>The second is optional.</p>
<p>If you want to follow along with this video and code alongside with me, you can go to the description below, I'm gonna have a link to my GitHub repo, go ahead and get cloned that and you'll be able to follow along.</p>
<p>The third is also optional.</p>
<p>If you totally want to follow along, you can go ahead to pieces dot app and install a pieces IDE integration along with the pieces Web Extension, and you'll be able to use pieces exactly the way I do in this video.</p>
<p>With that, let's get started.</p>
<p>So as I mentioned before, the extension we're going to create as a YouTube bookmarker.</p>
<p>Basically, anytime you navigate to a YouTube video page, an icon will show up on your YouTube video player to allow you to bookmark a particular timestamp on any video.</p>
<p>So let me show you how that's gonna work.</p>
<p>If you're on a YouTube video page, you're gonna see this item at the bottom right, you can go ahead and click that.</p>
<p>And if you navigate to your chrome extension icon at the top, right, I've pinned, so it's showing in the toolbar, you're going to see a new timestamp already had one timestamp for 15 minutes, I added one with an hour 18.</p>
<p>I can go ahead and delete this one because I just decided I don't want it.</p>
<p>And our extension is going to give us that ability to do that.</p>
<p>Now if I want to go back to my 15 minute timestamp, you can click the play button.</p>
<p>And it goes directly back to that particular timestamp.</p>
<p>I can also delete this one too.</p>
<p>And when there's no bookmarks to show, it's going to say there's no bookmarks to show.</p>
<p>Now we're going to add one, just so we have one on this video, and I can show you how storage works.</p>
<p>I'm going to navigate to a new video.</p>
<p>And in this video, we have no bookmarks.</p>
<p>So it says there's no bookmarks to show.</p>
<p>But if we go back to our previous video where we saved a bookmark, it's going to load that previous bookmark.</p>
<p>Now the last thing is if you navigate to a non YouTube video page, it's going to say this is not a YouTube video page in the extension UI.</p>
<p>And that's basically all the capabilities of this chrome extension we're going to build out here, there's going to be a lot more you can do on your own afterwards.</p>
<p>Now I also want to mention the reason we're working on creating this extension in particular, is because it's going to show you all the major parts of creating a Chrome extension, it's going to show you how to work with a content script to manipulate the DOM.</p>
<p>It's going to show you how to create a UI for your extension.</p>
<p>And it's going to show you how to use service workers as background scripts, which is a major part of the ship from manifest v2 to manifest v3.</p>
<p>And to start working on the extension.</p>
<p>Once you get cloned my repo, you can go ahead and click on the puzzle piece in your Chrome browser at the top right click Manage extensions.</p>
<p>I'm going to go ahead and remove my extension show I can show you how it works.</p>
<p>And you're going to see this developer mode option at the top right go ahead and toggle that so it's on click Load unpacked, then go to the repo that you get cloned in mind with the boilerplate code.</p>
<p>Go ahead and load that.</p>
<p>And we're going to see our extension here.</p>
<p>If we Click on this puzzle piece and pin it.</p>
<p>What we're going to see is this basic UI, it's just going to say your bookmarks for this video with no bookmarks, and it's just going to show up everywhere.</p>
<p>This is the default messaging in the boilerplate code I supplied and the boilerplate code will also contain all the files you need to follow along.</p>
<p>The best place to start with creating our extension is the manifest dot JSON file.</p>
<p>This file is a JSON file where we can specify what version of the extensions platform we will use, among other information that is going to serve as default for loading in our extensions.</p>
<p>Also, every extension you would want to create whether it's Safari, Mozilla, or any chromium base extension will need a manifest dot JSON file.</p>
<p>And it's probably the single most important file in your extension, because it simply just won't work without it.</p>
<p>In our boilerplate code already added the manifest dot json file, so we don't have to spend too long writing it out, I think it would be especially helpful if I just point out some of the things that you should note, in case you're creating your own extension.</p>
<p>So let's take a look here, as you'd suspect, there's a name, there's a version number and a description.</p>
<p>And basically, the version number is going to populate when you loaded in the extension, the name you see is also going to be the name of the extension when you load it in.</p>
<p>And the description is pretty self explanatory.</p>
<p>It's just a description of what the extension does.</p>
<p>Now things get more interesting with the permissions.</p>
<p>The permissions will be different depending on whatever Chrome extension you're building.</p>
<p>For this particular extension, we're going to request two permissions, which is going to be the permission to use the Chrome dot storage API, and the chrome dot tabs API.</p>
<p>The chrome dot storage API is to store things in the user's browser for the extension.</p>
<p>And the second permission, which is a chrome dot tabs API, is what helps us access our browser's tab system.</p>
<p>So we can read the tab for the extension.</p>
<p>This is basically going to help us identify what browser tab the user is currently using, and grab the URL to see if they are in a YouTube video page for our extension.</p>
<p>Now, the host permissions just give you the ability to send cause requests to certain host names.</p>
<p>Our extension only deals with YouTube pages.</p>
<p>So I have a match pattern written here just for YouTube.</p>
<p>The service worker, as I mentioned before, there's a big change between extensions, v2, and v3.</p>
<p>And one of the big changes is the use of a service worker.</p>
<p>As you can see here, the other is the ability to use promises.</p>
<p>But let's just focus on service workers.</p>
<p>For right now.</p>
<p>Service workers are just a JavaScript file that runs separately from the main browser thread.</p>
<p>This means that your service worker would not have access to content of a webpage, since it is separate from main processes.</p>
<p>However, your service worker does have capabilities to speak to your extension using the extensions messaging system, which we will see and use in our bookmarking extension.</p>
<p>The next thing I want to point out is the content scripts.</p>
<p>The content scripts are just files that run in context of the webpages we're on.</p>
<p>We're going to use this to manipulate the DOM of our webpage that our extension is looking at.</p>
<p>And here we're just specifying that our content script is represented by our content script J S file.</p>
<p>As you can see, with the J s colon content script dot j s, the last thing I want to point out is the pop up dot HTML file down here, under default pop up, this just specifies which file was served as our UI.</p>
<p>In our case, we've specified the pop up dot HTML file, and in that file, we specify that the corresponding file that helps it with its interactivity is a pop up.js file.</p>
<p>With all that out of the way, let's get to coding the actual extension.</p>
<p>We're now finally going to start writing code to make our extension work.</p>
<p>For us even test the extension, we have to add the button of the YouTube player that will allow us to save bookmarks with timestamps.</p>
<p>So in order for us to add a button on the YouTube video player will have to manipulate the DOM of the web page we are on.</p>
<p>What that means is we'll have to write our logic in our content script file, which operates in the context of the webpage, as I mentioned before, so let's go ahead and add some code to our content script file.</p>
<p>We're going to go ahead and add the following variables YouTube, left, controls, and YouTube player one is going to be for accessing the YouTube player one is going to be for accessing the controls.</p>
<p>And this is going to allow us to manipulate each of these.</p>
<p>But before we continue writing the logic to do DOM manipulation in the context strip, we also have to think about how our extension is even going to know when we've navigated to a new web page.</p>
<p>And we need to know this so the content script knows to execute logic to add the plus i Call to add bookmarks for our extension.</p>
<p>Let's go ahead and go in our background.js file now.</p>
<p>And what we want to do here is listen to any updates in our tab system and find the most recent tab or the tab that we're on currently and see if it's a YouTube page.</p>
<p>So we're going to have a listener, that's going to listen to tabs.</p>
<p>And if you remember, we got permissions to access the Chrome tabs API.</p>
<p>And we're going to listen for an update to tabs.</p>
<p>The parameters were given is a tab ID and a tab.</p>
<p>What we're going to do from here is see if there's a tab URL, and if there is a tab Euro.</p>
<p>Let's see if that Euro includes youtube.com/watch.</p>
<p>The way I came up with that is if you look at our YouTube video, every individual video has YouTube slash watch.</p>
<p>And we just want to make sure we're on a page that has that specifically as a URL, then what we want to do is set our query parameters.</p>
<p>And we're going to use query parameters as a unique ID for each video.</p>
<p>So we can grab it from storage, you'll see what I mean in a second, and I'll show you.</p>
<p>So we're going to do that by using the JavaScript split method.</p>
<p>What that means is basically after this question mark query parameter, we're going to grab this value.</p>
<p>And this is going to be our unique video value this right here after the equal sign.</p>
<p>And every video on YouTube has a different value right here.</p>
<p>So it's a pretty unique key that will help us store videos uniquely as well in our storage, and it's consistent.</p>
<p>So then we're going to add your URL parameters.</p>
<p>And this is just an interface to work with URLs URL search params.</p>
<p>And the final thing we want to do is there's a messaging system that happens between the extension, we're going to send a message to our content script that basically says a new video is loaded.</p>
<p>And this is the video ID of that video, and the video ID being that unique video value that we saw in the URL on YouTube.</p>
<p>And this tab, Id send message usage that I'm doing right here is all directly from documentation.</p>
<p>The Send Message takes a tab ID, it takes a unique object.</p>
<p>So right now I'm going to type type.</p>
<p>And this is a type of event is a new video event.</p>
<p>And then a video ID value, which is going to be URL, parameters, dot get v.</p>
<p>So if we're doing URL dot get D, it's going to grab this right here.</p>
<p>And that's basically going to be the code for that send message takes a tab ID, it takes an object and then it can also take a callback function.</p>
<p>This object right here doesn't have to be type or video ID, it could also be something random, like I could literally pass this and the content script will have access to random, and then the string random.</p>
<p>In our case, the only thing that's applicable is the type of the event and then the video ID, which is a content script needs.</p>
<p>Now in our content script, we're going to add a listener that is going to listen to any of those incoming messages, we need to be able to listen to that background.js message.</p>
<p>So to do that, we're going to end up writing the following code to add that listener, so we're going to say on message add listener.</p>
<p>And this is going to accept three parameters.</p>
<p>So an object a sender, and a response.</p>
<p>And the response is, when a message is being sent to the content script, we can also send a response back where the message is coming from.</p>
<p>So I'm going to destructure those values we're getting and if you remember, the way I'm deconstructing Type value video ID is basically we're given a video ID right here.</p>
<p>Later on, we're going to grab a value as well, and I'm just destructuring.</p>
<p>So each of these are its own variable.</p>
<p>So it's a If type is equal to new, so if the type of event is new video loaded, which we're getting from the background.js file, we want to be able to set current video, which will be a global variable in the content script as the video ID, and then we want to call a function to handle any actions with new video.</p>
<p>So we're going to call a new video loaded function.</p>
<p>And let's go ahead and set current video as a top level variable.</p>
<p>And that's just going to be an empty string.</p>
<p>But it's going to be set as the string set from the background at js file, once the message is received on this end, so let's go ahead and actually see if this works at all.</p>
<p>I'm gonna go ahead and just console dot log your parameters.</p>
<p>I'm gonna give this a reload.</p>
<p>Open this, let's inspect it.</p>
<p>And we ever URL search parameters.</p>
<p>So we know we're getting our URL search parameters now.</p>
<p>Great.</p>
<p>So so far, things look good.</p>
<p>Now, what we want to do from here is create that new video loaded function.</p>
<p>And after we create this function and all the functionality surrounding it, we should see the YouTube player button on the YouTube video.</p>
<p>So let's go ahead and do that.</p>
<p>So what we're going to do is create this function that we have right here called New Video loaded.</p>
<p>And what we probably want to do is check if a bookmark button already exists, I know the class name that this item has, it's called Bookmark button because I wrote the CSS code that's going to style this whole extension.</p>
<p>So you could just copy this part right here.</p>
<p>But this is just some native Dom, slash JavaScript methods that we can use.</p>
<p>It's actually MB by class name.</p>
<p>And it's going to return an HTML collection.</p>
<p>So what we're going to do is grab the first element that matches this class name, bookmark button.</p>
<p>And it's just going to exist on every single YouTube video page.</p>
<p>So if we want to test that, we could just say console dot log bookmark exists.</p>
<p>And let's reload this page and inspect it.</p>
<p>Actually, let's reload our extension as well.</p>
<p>reload this page inspect, we're probably going to get undefined this is exactly what I expected.</p>
<p>Because we don't have any logic surrounding the Bookmark button yet.</p>
<p>And also, we're not even setting a bookmark button right now.</p>
<p>So if the Bookmark button did exist, we would get true, it does exist, but we're getting undefined right now.</p>
<p>So what we want to do if we're getting that undefined or false value that a bookmark button does not exist, is add some logic to say, hey, let's add this bookmark button to any YouTube player.</p>
<p>So we're going to create an image element.</p>
<p>That is going to be the image we click on for bookmark buttons.</p>
<p>As part is in, we're going to add a couple of attributes.</p>
<p>The first thing we're going to want to do is pull the image that we're using, which is our assets slash bookmark.</p>
<p>PNG, you already have this if you're following along with the boilerplate code.</p>
<p>The second thing we want to do is add a class.</p>
<p>And the way we're going to add this class is basically make it pretty dynamic here.</p>
<p>So we're gonna add a YouTube button class with a space and then we're going to add a bookmark button class in quotes.</p>
<p>And this is again, just some styling I have that you don't need to worry about right now.</p>
<p>And the last thing we want to do is basically on hover, we want to make a title show.</p>
<p>So we're just gonna say the title is click to bookmark, current timestamp.</p>
<p>And this is just a UI thing.</p>
<p>You'll see this in a bit.</p>
<p>Next, what we want to show is a way to grab the YouTube controls.</p>
<p>So these are the YouTube controls over here, we want to be able to grab these left controls, so we can add a bookmark button right here.</p>
<p>So let's go ahead and find out how to do that.</p>
<p>I already know how to grab this.</p>
<p>So I'm going to show you how this works.</p>
<p>You can inspect elements right here and find what elements they exactly are.</p>
<p>But basically, we're going to use native JavaScript methods like we have done previously to grab those controls and insert our button.</p>
<p>If I do document dot get elements by class name.</p>
<p>And I grab YouTube left controls, we should get an element back, which is going to be this div class over here.</p>
<p>And you can see that it gives us all the left controls over here, where we're going to add our button.</p>
<p>And the second thing we're going to want to do is grab the YouTube player as well.</p>
<p>And that's one of the global variables we set in our content script.</p>
<p>And we can also do that by writing document dot get elements by class name.</p>
<p>And then there's this video stream class.</p>
<p>And we're going to grab the one at the zero with index.</p>
<p>And it grabs a whole YouTube component right there.</p>
<p>So now we know the two elements in the DOM we need to manipulate.</p>
<p>But let's set those elements.</p>
<p>First, we're going to do exactly what we saw in our content over there.</p>
<p>Document get, actually, I'm just gonna go back and copy these way easier, so don't make a mistake.</p>
<p>And then the second one is going to be YouTube player.</p>
<p>Go back and copy that.</p>
<p>Okay, so after this, what we're going to want to do is add that bookmark button and we grabbed the controls, you saw that row in the player, we want to add it to that row.</p>
<p>So we're going to type out YouTube left controls to get those left controls we stored in a variable.</p>
<p>And to use this native JavaScript method we can use called append child, which is going to append this bookmark element inside that row.</p>
<p>And then the second thing we're going to want to do is probably add a listener to listen to any clicks on our icon.</p>
<p>So there's a correction I want to make to this portion of the video, before we continue, it's going to be very important in order for your extension to work functionally.</p>
<p>And it's only one line of code, but it's going to make such a big difference.</p>
<p>I'm also going to explain an important concept of Chrome extensions that I neglected while I was writing this, which is that in our manifest json file, we have a match pattern for our content script.</p>
<p>And basically the match pattern, we currently have checks if any youtube.com video is loaded.</p>
<p>And if it is, we're injecting our content script into the context of that web page.</p>
<p>So basically, what that means is, anytime a youtube.com page shows up, we're running a bunch of logic using our content script.</p>
<p>But the problem right now is that our background.js file is telling us when a new video is loaded.</p>
<p>And the event listener we're using is on updated, which is just checking if this URL is updated.</p>
<p>If you refresh this page, the URL is not updated.</p>
<p>So this button actually isn't going to show up.</p>
<p>And if you continue coding without this fix right here, you're gonna see some edge cases that you might not like.</p>
<p>So let's go ahead and fix this, we're going to do a super simple fix.</p>
<p>It's not the best fix in the world, but it will fix the problem here.</p>
<p>We're just going to call a new video loaded anytime our content script matches youtube.com.</p>
<p>And what this is going to do is call this new video loaded function anytime we hit that match pattern.</p>
<p>The downside of this is now if the background script sees it as a new video using the on updated event listener, and there's a condition that content script is injected, we're going to hit this or call this new video loaded function twice, you can fix this pretty easily by just adding a conditional make sure that doesn't happen.</p>
<p>But to make sure everyone is able to follow along with this correction, I will not be doing that here.</p>
<p>And we'll just only be inserting this one line of code calling the new video loaded function.</p>
<p>Luckily, the only thing our new video loaded function is doing is adding the Bookmark button to the YouTube player.</p>
<p>So there's going to be no negative implications to calling it twice.</p>
<p>Since we have a condition that checks if the button is already on the player.</p>
<p>It's just not the most efficient implementation, which is fine for the sake of this tutorial.</p>
<p>With that, let's continue with the rest of the video.</p>
<p>There we go, we have the button right there.</p>
<p>But right now, if we click the button, it's not doing anything.</p>
<p>And there's a reason for that.</p>
<p>The reason is that we don't have any event listener listening to click on this particular byte.</p>
<p>Let's go ahead and add the code for that.</p>
<p>So what we're going to do is add an event listener to listen to a click for the button.</p>
<p>And we're literally going to use the add event listener method, listen for a click, and then call a function called add new bookmark event handler.</p>
<p>And this is a function we have not coded yet.</p>
<p>So to make this function work, we're going to have to do the following.</p>
<p>We're probably going to have to figure out the timestamp of the video at which point someone presses the button.</p>
<p>This is basically going to help us figure out what our bookmark should be saved as in storage according to its timestamp.</p>
<p>So how are we going to do that? How are we going to figure out the YouTube video timestamp.</p>
<p>Again, YouTube makes this pretty accessible, it can be found as an attribute.</p>
<p>So what we're going to want to do is grab the YouTube player.</p>
<p>And we already have a global variable that has it.</p>
<p>But I'm just going to grab it again.</p>
<p>So we can see how to do this in the console, I'm going to create a YouTube player variable in our console.</p>
<p>Okay, now we have it saved.</p>
<p>And then on YouTube player, there's going to be a property called current time.</p>
<p>And it's going to give us the current time in seconds.</p>
<p>And in order for us to save our bookmark, according to hours, minutes seconds, we're probably going to also have to create a function that converts seconds into a standard time of how it's displayed in YouTube.</p>
<p>So let's go ahead and start with all those things.</p>
<p>We're gonna go ahead and add the function, add new bookmark event handler.</p>
<p>And we're going to use the exact property we saw in our console, YouTube player dot current time, which is going to give us the current time.</p>
<p>And we're going to say okay, now, this is only called when a new bookmark is made.</p>
<p>So let's create a new bookmark variable.</p>
<p>And this is going to be an object that has the time of the bookmark and a description.</p>
<p>And the description is just going to end up being the title that's going to be displayed in the Chrome extension.</p>
<p>So it's going to be a dynamic description and skins a bookmark at current time.</p>
<p>However, the problem is that this is in seconds, as we said before, so we're going to have to convert this.</p>
<p>So we're going to use a function called get time.</p>
<p>I'm just going to insert it using pieces.</p>
<p>So I'm going to do is go over to this time function here.</p>
<p>Insert snippet.</p>
<p>And there it is.</p>
<p>So now I'm able to convert my seconds into time.</p>
<p>And then the last thing I want to do here is sync it to Chrome Storage.</p>
<p>And what this is going to do is set Chrome storage with each bookmark.</p>
<p>So basically, each video, according to its video identification number that we're grabbing from the URL will also map back to a set up bookmarks in Chrome Storage.</p>
<p>So to do that, we're going to do Chrome storage sync.</p>
<p>And again, if you're interested in this, you can look in documentation to find out what this function takes.</p>
<p>Current video, it's important to remember that things need to be stored in JSON in Chrome Storage.</p>
<p>So I'm going to do JSON stringify.</p>
<p>All my current video bookmarks, so I'm actually going to add it A variable up here that's going to store all current video bookmarks in an array.</p>
<p>And I'm going to spread that.</p>
<p>So we can add a new bookmark to those set of current video bookmarks.</p>
<p>And then the last thing I want to do here is sort bookmarks by their save timestamp in our Chrome Storage.</p>
<p>So we're going to sort by time, and this is just coming from this right here, every bookmark is going to have a time and a description.</p>
<p>So we're going to look at that and sort accordingly.</p>
<p>Great.</p>
<p>Now, if we reload our extension, we're going to see if it works as expected.</p>
<p>And the way to see that is basically console dot log, this new bookmark, let's do it.</p>
<p>Great.</p>
<p>That time it worked, we just had to give it another reload.</p>
<p>And we got a time in seconds and a description.</p>
<p>Now the final thing I want to do is complete this file before we go to the UI.</p>
<p>And we want to make this fully functional to fetch all bookmarks when a new video is loaded.</p>
<p>To do this, we're going to grab asynchronously, all bookmarks from Chrome storage, which means I'm going to write a promise that resolves once we retrieve all bookmarks.</p>
<p>So that code is going to look like this.</p>
<p>I'm going to create it at the top here.</p>
<p>And I'm going to say const fetch bookmarks.</p>
<p>And I want to return a promise.</p>
<p>So we can resolve this asynchronously.</p>
<p>And within that promise, I'm going to fetch from Chrome Storage.</p>
<p>So I'm going to do a Chrome storage sync and we did a set before to set Chrome Storage, we're going to get this time our current video it takes an object.</p>
<p>And we're going to resolve to find any bookmarks when indexing using our current video.</p>
<p>So basically look in storage to see if our current video has any bookmarks, or if it exists in storage.</p>
<p>That's what's happening right here.</p>
<p>If it does exist, we're going to JSON dot parse it because we JSON dot Stringify before, if it doesn't, what we want to do is return an empty array.</p>
<p>And this should work.</p>
<p>And we're really only going to add these in two places, which will be in our new video loaded function.</p>
<p>So we're going to make this async.</p>
<p>And we're gonna add a fetch bookmarks.</p>
<p>So actually, we're just going to add this to our current video bookmarks variable.</p>
<p>And call a weight fetch bookmarks.</p>
<p>async await is going to resolve this promise.</p>
<p>And then the second place we want to add this is to our add new bookmarks event handler.</p>
<p>To basically handle this case, and make sure we're always using the most up to date set of bookmarks when destructuring.</p>
<p>So we're gonna do current video bookmarks equals the weight, veg bookmarks.</p>
<p>And also make this async.</p>
<p>Awesome.</p>
<p>So for right now, we finished everything we need for our content script file, obviously, things aren't going to show in the UI.</p>
<p>And we could check that out right here.</p>
<p>And as you can see, there's nothing in the UI because everything we've been doing so far has been manipulating the DOM right here to add the icon.</p>
<p>Add some logic to get us ready to create a UI for our extension.</p>
<p>Let's go ahead and start making some UI components show starting out with some bookmarks from clicking that addition button in the YouTube player that we added.</p>
<p>Now, the first thing we need to figure out on any given page is if it's a YouTube video page or not, if it is, we're going on one of fetch any bookmarks we may have from Chrome Storage.</p>
<p>And if it's not, we'll just want to display some messaging saying it's not a YouTube page.</p>
<p>If you open the Chrome extension on a page that is not YouTube.</p>
<p>So to do this, we're going to add a utility function that's going to allow us to decipher that logic.</p>
<p>So we're actually going to grab our utility function to find the active tab that the user is on through Google Chrome documentation.</p>
<p>I'm going to go ahead and use this example right here, which helps us retrieved a currently focused tab from the Chrome documentation.</p>
<p>And since I have the google chrome pieces extension over every codeblock, whether it's in documentation or Stack Overflow, I'm able to directly save on pieces with this icon that shows up at the top right of any code block, I'm going to go ahead and save it.</p>
<p>And then I'm going to go back to my VS code and refresh my pieces tree, I can go ahead and insert this snippet, which is the newest one, and I'm just going to rename this snippet to active tab Chrome.</p>
<p>Amazing and pieces automatically classified this as JavaScript, because it was able to decipher that from some machine learning.</p>
<p>I'm gonna go ahead and delete this background.js comment.</p>
<p>And awesome, we now have a function that grabs the current tab.</p>
<p>But also, I want to make sure I'm exporting this function.</p>
<p>So I'm going to add export.</p>
<p>And then what I want to do is open up the pop up.js file.</p>
<p>And over here, we're going to want to import that function at the very top.</p>
<p>So we can use it here.</p>
<p>So I'm going to import Get active tab URL from utils dot j, s, and I actually don't think the documentation called it this.</p>
<p>So I'm going to go ahead and change the function name, so it matches.</p>
<p>So go to utils.js.</p>
<p>Change that thought, awesome.</p>
<p>Now, the event we want to listen to when opening the pop up.js file is the DOM content loaded event, which is right here.</p>
<p>This event is a native window event that fires when an HTML document has initially been loaded.</p>
<p>It's essentially when we want to load all our bookmarks and show them.</p>
<p>So we're going to type the following to do so what we're going to do is grab our active tab function first.</p>
<p>And we're going to look at the user's current active tab, which we already have the function for from in the utiles.</p>
<p>It's an async function.</p>
<p>So we're going to async await this.</p>
<p>And then after that, we're going to grab the query parameters to help us identify the video.</p>
<p>If you remember, each YouTube video has a unique identifier.</p>
<p>After the question mark, where the query parameter, we're going to grab that, we're going to use a URL search params to be able to get the unique identifier for each video.</p>
<p>And to get the unique identifier, we're going to create it current video variable and do URL parameters dot get V.</p>
<p>And this is just based off of what the YouTube video URLs look like.</p>
<p>Now, our active tab URL should have youtube.com/watch Because any specific YouTube video always has this in its URL.</p>
<p>And we want to make sure we're watching a YouTube video when our Chrome Extension has any logic with bookmarks.</p>
<p>And we want to make sure this current video variable is truthy meaning is get actually returned something other than undefined or any falsie value.</p>
<p>And then what we want to do is we want to get any current video bookmarks from Chrome storage.</p>
<p>If you remember, we're setting Chrome storage with the current video as a key and then all the bookmarks as a value that is JSON ified.</p>
<p>And in order for us to retrieve those bookmarks, we need to use a Chrome Storage API to get them.</p>
<p>So to do that, we're going to grab the video bookmarks using Chrome storage sync get.</p>
<p>And we're going to get it with the current video unique identifier, which is the YouTube videos unique identifier in the URL.</p>
<p>And then, we are going to set a current video bookmarks variable which is going to contain all those JSON ified current videos.</p>
<p>And in order for us to pass this to any function or write some custom logic to show bookmarks, we're going to have to JSON dot parse any bookmarks that are saved in Chrome Storage since it's in JSON, and we can't really work with that.</p>
<p>But if there are no bookmarks or chrome searches and return anything, we're just going to want to return an empty array.</p>
<p>Now, we're going to have to pass this over to the view bookmarks function, which is basically going to help us view any bookmarks in our extension, that Chrome Storage dot get returns.</p>
<p>But before that, I'm just going to put a comment right here.</p>
<p>So we remember, we want to handle this else condition, which basically is for the scenario where we're not on a youtube.com video page, or current video returns a falsie value.</p>
<p>So what we're going to want to do is add a message that says this is not a YouTube video page.</p>
<p>And let's just go back to chrome to look at what that might look like.</p>
<p>So this is our UI Currently, we have this container class right here that encapsulates and will eventually encapsulate all the bookmarks we have, it has this container class name in it, what we're going to want to do is get that class name.</p>
<p>So we're just going to do this in the console before we actually do in code just to make sure it works.</p>
<p>The class was called container.</p>
<p>And that's just from the CSS in the boilerplate, so you don't have to worry about it, or the HTML or other.</p>
<p>So when we wrote this document that get elements by class name container, and then grab the first element in the HTML collection, we get this element right here, which is containing all these other elements within it.</p>
<p>And what we want to do here is basically specify in the HTML on pages that are not youtube.com.</p>
<p>So this is actually a YouTube video page.</p>
<p>We don't want to display this message.</p>
<p>But I just want to show how this is going to look, we're going to want to put a new div class that says, This is not a YouTube video page.</p>
<p>And let's just see if that works.</p>
<p>Will this change the extension the way we want it to? Container is not.</p>
<p>So what we need to do over here is actually encapsulate this in a variable.</p>
<p>We're gonna set this equal to container.</p>
<p>And now let's try that.</p>
<p>And change the extension to show that this is not a YouTube video page.</p>
<p>So the way we're going to do this dynamically in our code is basically put all the code we just put in our console to test this out within our else conditions.</p>
<p>So every time we're not on a YouTube video page, or this returns a falsie value, we want to show that this is not a YouTube video page when we try to open up the Chrome extension in those scenarios.</p>
<p>So we're gonna say const container equals document dot get elements by class name, right there.</p>
<p>Grab that container class, first element.</p>
<p>And then set the inner HTML, set that equal to div class equals title, title, just add some styling, that's going to make it look slightly nicer.</p>
<p>There's nothing super special about the styling I have.</p>
<p>And since we tested it, there shouldn't be really any surprises here, it should pretty much work as expected.</p>
<p>So let's go ahead and give this extension a reload.</p>
<p>It shouldn't show the message for this page.</p>
<p>It doesn't.</p>
<p>But if we go to a non YouTube page, it's gonna say this is not a YouTube video page.</p>
<p>Amazing.</p>
<p>So let's go back to our code.</p>
<p>And we're going to want to write this view bookmarks function.</p>
<p>So if it does meet the conditions of being on a youtube.com/watch page, and as you can see, that's from any page that has a video page, it has youtube.com/watch on it.</p>
<p>And your URL params dot get the return something so it's a truthy value, we're going to want to view the bookmarks associated with that video.</p>
<p>So let's go ahead and call the view bookmarks function and pass it all the current video bookmarks.</p>
<p>And we're gonna go ahead and write the logic that's going to help us populate the UI with all the bookmarks we grabbed from Chrome Storage.</p>
<p>So to do that, we're going to pass it current bookmarks.</p>
<p>And we're going to set a default argument of an empty array just in case nothing is passed to it.</p>
<p>It's just going To return or show no bookmarks, and we're going to grab a bookmark element.</p>
<p>Again, this is just from the HTML that I have given you.</p>
<p>So it's not anything you need to worry about.</p>
<p>If you want to try this out on your own, you could try it out in the console.</p>
<p>Just to save some time here, I'm just going to write the code here.</p>
<p>And you can go ahead and copy it.</p>
<p>But again, it's just knowing how to work with the DOM and inspecting the elements to figure out how to do this.</p>
<p>What I'm doing right here is basically saying, Okay, if there are any bookmarks, let's just set it to nothing.</p>
<p>So we're not displaying anything, we're going to reset the whole thing, since we're calling this function to view bookmarks.</p>
<p>And we're going to have new bookmarks being passed in, which is the current bookmarks.</p>
<p>And we're gonna say if the current bookmarks length is greater than zero, meaning if there are current bookmarks, and it's just not an empty array.</p>
<p>Let's go ahead and iterate over all those bookmarks, and show them in our UI.</p>
<p>So to do that, we're going to iterate over every bookmark in a loop.</p>
<p>And then, we're going to grab the bookmark through indexing, so current bookmarks.</p>
<p>With whatever iteration we are in the loop.</p>
<p>And then what we're going to have to do from here is call another function, add new bookmark, which is going to add a new bookmark row to our pop up, I'm going to go ahead and remove this comment.</p>
<p>And we're going to add a new bookmark, we're going to pass it the bookmark element up here, which is going to populate all our bookmarks, well, it's going to be where we add each of our rows.</p>
<p>And I'm going to pass it each specific bookmark.</p>
<p>So we're going to add one bookmark at a time and call this function every time we're adding a bookmark.</p>
<p>But before that, what we want to do is if there are no bookmarks to show meaning, current bookmarks is just an empty array.</p>
<p>We're going to want a message that says there are no bookmarks here.</p>
<p>So we're going to set a message using italics.</p>
<p>Saying no bookmarks.</p>
<p>To show before we move on and add individual bookmarks to our pop up, let's go ahead and check if this condition works.</p>
<p>Where there's no bookmarks to show and since we haven't added any bookmarks to this YouTube video that I'm looking at right now, it should work, I want to go ahead and reload my extension.</p>
<p>Yep, there it is no bookmarks to show.</p>
<p>Amazing.</p>
<p>So now we're going to want to finally handle the case where we have bookmarks to show and this is going to allow us to start seeing bookmarks in our UI.</p>
<p>So the first thing we're going to do is go to our add new bookmark function.</p>
<p>And it's going to accept bookmark bookmarks element, and it's going to accept a bookmark.</p>
<p>And then what we're going to do from here is we're going to create two elements.</p>
<p>So one element is going to be for the title, which is going to display in the UI of each bookmark.</p>
<p>And then one element is going to be the whole bookmark element that will contain the title will contain the delete button and will contain the play button.</p>
<p>So let's go ahead and create the bookmark title element.</p>
<p>And then after this one, we're also going to create the new bookmark element, which will encapsulate all these other elements that are part of a bookmark row.</p>
<p>From here, for bookmark title element, we're going to want to display what the bookmark is and give it a title.</p>
<p>So we actually already created the title.</p>
<p>The title is the description of the bookmark.</p>
<p>If you remember in our bookmark object, there's a timestamp and a description.</p>
<p>So we're gonna set our text content to the description, which is bookmark dot description.</p>
<p>And then our class name is going to be bookmark title element dot class name, and that will be set equal to bookmark title and this is just going to add some In CSS, that is already in our boilerplate code.</p>
<p>Now for the general component that is going to encapsulate all the play button, the title, a delete button, anything else you might want to add, we're going to do a couple things, the first thing we're going to do is give it an ID of bookmark element, or bookmark with the bookmark time, and this is going to guarantee a unique ID for each element that is a row.</p>
<p>So if you save any bookmark, what's gonna happen is there's going to be a row associated with each bookmark, which is our new bookmark element.</p>
<p>And there's going to be an ID set for that row, which will be the bookmark along with the time and they'll help us uniquely identify any specific row in the UI.</p>
<p>And that's later we're going to be used to delete elements when we're deleting a specific bookmark.</p>
<p>And then we're going to set a class name, which was is going to help with some styling, that's going to be set to a bookmark class.</p>
<p>And then we're going to set an attribute which is going to help us with playing a video.</p>
<p>Because basically, we're going to want to know the timestamp of any specific bookmark.</p>
<p>So when we play the video, we know where to set the video player at what time we want to send it out and the attribute of the bookmark element will help us find that.</p>
<p>And the final things we want to do here is, since we know the new bookmark element is encapsulating all these other things, we want to append child bookmark title element, so it displays within the new bookmark element.</p>
<p>And then we have this bookmarks element that is passed in.</p>
<p>And we're going to append our new bookmark element, which is this element that's encapsulating all the other things.</p>
<p>Inside that since it's a container.</p>
<p>So now if we go back to our UI, and we give this a reload, just in case, let's just go to a new video.</p>
<p>And if I press plus, in this video, we see a new row, it says bookmark out one hour, 54 minutes.</p>
<p>And we could add another row if we want.</p>
<p>And it gives us that same row, we're going to handle the case of the deletion, there's an ADD, it's just going to set at zero seconds.</p>
<p>Bookmark it 000.</p>
<p>Awesome.</p>
<p>So that works.</p>
<p>So now let's go back work on some additional functionality.</p>
<p>Right now, we have no functionality associated with each bookmark yet, so we can't play any particular timestamped bookmark, we can't delete a bookmark.</p>
<p>And the next thing we want to add is the play button.</p>
<p>So to do this, we're going to add a play button to each bookmark that will go directly to this timestamp that we have saved for each video.</p>
<p>To start off, we're going to have to add a function that is going to add an icon for a play button, listen for a click and call a function or event listener that will perform the logic to set a video at a particular timestamp.</p>
<p>The function will end up looking something like what we're going to code right here in a second.</p>
<p>And we're going to keep it super generic because it's going to be used for both our delete and play functionality.</p>
<p>So the functions are going to take a source attribute an event listener.</p>
<p>And it control parent element.</p>
<p>And when we say control elements in our code, it means the play button, the delete button, we're just calling those control elements.</p>
<p>So we're going to create a control element.</p>
<p>And this is just one particular functionality, we're going to call a singular control element.</p>
<p>So in this specific case, we're going to have a play button.</p>
<p>But again, this is a generic function.</p>
<p>So think of this as like a play button, a delete button we want this singular control element will be one of those.</p>
<p>And then those control elements will be linked to a image in our assets folder.</p>
<p>So if we want to play button, what we're going to link to is assets slash play dot PNG, and our schema super are predictable for this.</p>
<p>So all we're going to do is assets plus SRC plus dot png.</p>
<p>And there's definitely a better way of doing this, you can go back and work on the code after this video.</p>
<p>But we're just going to keep it super simple for right now.</p>
<p>We're going to give it a title that is the same as our source attribute, or what we pass in this source.</p>
<p>So what's going to get passed in here is play, edit, delete, whatever.</p>
<p>And the title will be set to that.</p>
<p>So in this particular case, for play, it will be set as play, we're going to add a event listener.</p>
<p>And that event listener will listen for a click.</p>
<p>And we're going to pass it a function that is going to be performed on that clique.</p>
<p>And the last thing we want to do is there's going to be a container for all control elements.</p>
<p>And we're passing that in into this function.</p>
<p>And we're calling it control parent element.</p>
<p>So we're going to append this singular control element to the parent element.</p>
<p>And the next thing we're going to want to do is add the function call that will add a play button, a title and our event listener to each individual bookmark.</p>
<p>So in the add new bookmark function that we coded earlier, we're going to add a couple lines of code here.</p>
<p>And these couple lines of code are going to add those control elements.</p>
<p>So we're going to go ahead and create the element that's going to hold all our buttons, we're going to call it the controls element.</p>
<p>And it's going to be a div just like these other ones, we're going to keep it super simple.</p>
<p>And then what we want to do is give this some styling.</p>
<p>We're going to add the class name, but controls, and you're just gonna have to trust me on this it exists.</p>
<p>Then what we want to do is set our attributes using a set bookmark attributes function that we created, we're going to pass in play, the event listener is going to be called on Play.</p>
<p>And we're going to code that later.</p>
<p>And then we're going to pass in the controls element, which is going to be the parent element.</p>
<p>And the last thing we want to do is append this to our new bookmark element.</p>
<p>And what we should see now is, this play button is going to show up in our extension.</p>
<p>So let's check this out.</p>
<p>Let's give this a refresh over here.</p>
<p>There we go.</p>
<p>We have the play button in our extension.</p>
<p>But what we're going to notice is it's actually not going to work.</p>
<p>Let's go ahead and play this video.</p>
<p>And we're going to try to get it back to 26 minutes, 51 seconds, it doesn't work.</p>
<p>And the reason it doesn't work is we still need to code the On Play Event listener.</p>
<p>So let's go ahead and do that.</p>
<p>What we're going to do is we're going to target the timestamp attribute that we set earlier.</p>
<p>So again, you could check this out in your console or inspect, if you want to get a visualization of how this is gonna work.</p>
<p>But I'm gonna go ahead and type this for the sake of time.</p>
<p>And we're gonna get that timestamp we set earlier.</p>
<p>And then the second thing we're gonna want to do is grab the active tab, and that's just using the active tab function.</p>
<p>This is an async await function.</p>
<p>So we're going to have to async await it.</p>
<p>And I'm going to add async to make this async function.</p>
<p>So we've actually run into a problem now, we need to send the content script and message to manipulate the YouTube player to set it at the timestamp that the bookmark is placed on.</p>
<p>So in this file, we're going to have to add that message.</p>
<p>Let's go ahead and send a message to the content script.</p>
<p>And this is going to follow the same pattern of how we did it with our background script.</p>
<p>Missing comma there.</p>
<p>We're going to have type of play.</p>
<p>That's going to be our event type.</p>
<p>And then the value is going to be bookmark time.</p>
<p>And then in our content script, we're going to have to be able to read this message.</p>
<p>So what we're going to do is add a condition to our On message listener here, and we're going to say if the type is play.</p>
<p>Let's set the YouTube player time equal to the value that's passed in.</p>
<p>So basically, let's just take a look at this for a second.</p>
<p>If it's sending a message of type play, and the value is the bookmark time, then what we want to do is take that value and set it to the YouTube Players Current Time, which will make it the time of the bookmark.</p>
<p>And let's go ahead and see if this works.</p>
<p>I'm going to go ahead and reload my extension.</p>
<p>And let's go ahead and go to the extension, we have a bookmark at 26 minutes, 51 seconds, it's currently at 48 minutes, 30 seconds.</p>
<p>Let's hit that play button.</p>
<p>It goes back to 26 minutes, 51 seconds.</p>
<p>And now if we also hit this addition button anywhere in the video, we should get a new bookmark, we have two hours, 21 minutes, 22 seconds.</p>
<p>Now let's go forward.</p>
<p>Let's press play here.</p>
<p>And it goes back to that time.</p>
<p>Awesome.</p>
<p>So now our Play button works.</p>
<p>The last functionality we want to build is the ability to delete a bookmark, which will be super similar to what we did for the play button.</p>
<p>The first thing we're going to want to do is go to our POP up.js file.</p>
<p>And we're going to add the delete button to our controls element with the code in our add new bookmark function to set bookmark attributes.</p>
<p>So let's go ahead and do that we're going to pass in delete our on delete event listener, and then the parent controls element.</p>
<p>And since we set the undelete function as the event listener, we need to code some operations that are going to take care of our deletion.</p>
<p>So we already know we're going to use this active tab that we used over here.</p>
<p>So let's go ahead and already create this an async function ahead of time.</p>
<p>Let's grab the user's active tab.</p>
<p>And then what we're going to want to do is grab the timestamp attribute that we set earlier.</p>
<p>And it's going to be the same code from up here.</p>
<p>So I'm just going to copy and paste there we go.</p>
<p>Then what we're going to also want to do is grab the element that we want to delete.</p>
<p>So if you remember, I created a specific ID linked to timestamps, what we're going to want to do is grab elements by the ID so we can delete them.</p>
<p>So bookmark.</p>
<p>Plus the bookmark time will give us the element we want to delete.</p>
<p>And then what we're going to do is delete that element by going to the parent node, and then removing the child which will be the element we want to delete.</p>
<p>And then the final thing we're going to want to do here is send a message to our content script.</p>
<p>Saying we want to perform a deletion type of delete, and then the value is going to be bookmark time.</p>
<p>And there's one final thing we want to do here, this Send Message function from the Chrome's tabs API actually takes a callback function optionally.</p>
<p>And we're going to pass one in which is going to be our view bookmarks function.</p>
<p>And that's just going to refresh our bookmarks, so any deletions show up immediately, then in our content script, we're going to add another condition, which is basically you're going to ingest that Delete message.</p>
<p>So we're going to say else if type equals Delete.</p>
<p>The current video bookmarks will be equal to current video bookmarks.</p>
<p>Filter, and we're going to filter by time.</p>
<p>So the time is not equal to the value being passed in because that's a value we're deleting.</p>
<p>And then the final thing we want to do is sync Chrome Storage.</p>
<p>So if this page reloads, this deleted bookmark does not show up.</p>
<p>We're going to JSON fi, error bookmarks and If that should work, the last thing we want to do is add a way to send the updated bookmarks back to our POP up.js file in order to display the most recent bookmarks, and we'll do the following.</p>
<p>To do that, we're going to send a response of current video bookmarks.</p>
<p>So now we can go ahead and try out deleting a bookmark with a reload of our extension, and it should start working.</p>
<p>So I'm going to go back to our Chrome browser, reload our extension.</p>
<p>And if we go ahead and delete a bookmark, we're gonna see that they're deleted.</p>
<p>If we go ahead and add a bookmark, we're gonna see there's a new bookmark.</p>
<p>It's at a different timestamp the YouTube players a different time.</p>
<p>So if we play, it goes back to the timestamp of the bookmark.</p>
<p>We want to delete again, it's going to delete.</p>
<p>The last thing we're going to want to do is distribute our extension.</p>
<p>However, I'm not going to quite go over that in this video, because Google gives great documentation that serves as a step by step guide on how to go about publishing your chrome extension to the Google web store for anyone to download.</p>
<p>And with that, the videos over you know everything you have to do in order to create a modern web extension using manifest v3, and I'll see you next time.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Create Your Own Google Chrome Extension ]]>
                </title>
                <description>
                    <![CDATA[ By Sampurna Chapagain If you are a Google Chrome user, you've probably used some extensions in the browser.  Have you ever wondered how to build one yourself? In this article, I will show you how you can create a Chrome extension from scratch. Table ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/building-chrome-extension/</link>
                <guid isPermaLink="false">66d460f038f2dc3808b790df</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CSS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ HTML ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ freeCodeCamp ]]>
                </dc:creator>
                <pubDate>Thu, 03 Feb 2022 19:42:43 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2022/02/World-Wildlife-Day--1-.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>By Sampurna Chapagain</p>
<p>If you are a Google Chrome user, you've probably used some extensions in the browser. </p>
<p>Have you ever wondered how to build one yourself? In this article, I will show you how you can create a Chrome extension from scratch.</p>
<h2 id="heading-table-of-contents">Table Of Contents</h2>
<ul>
<li><a class="post-section-overview" href="#heading-what-is-a-chrome-extension">What is a Chrome Extension</a>?</li>
<li><a class="post-section-overview" href="#heading-what-will-our-chrome-extension-look-like">What will our Chrome Extension Look Like</a>?</li>
<li><a class="post-section-overview" href="#heading-how-to-create-a-chrome-extension">How To Create a Chrome Extension</a></li>
<li><a class="post-section-overview" href="#heading-manifestjson-file">Creating a manifest.json file</a></li>
<li><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></li>
</ul>
<h2 id="heading-what-is-a-chrome-extension">What is a Chrome Extension?</h2>
<p>A chrome extension is a program that is installed in the Chrome browser that enhances the functionality of the browser. You can build one easily using web technologies like HTML, CSS, and JavaScript.</p>
<p>Creating a chrome extension is similar to creating a web application, but it requires a <code>manifest.json</code> file which we will discuss in the last section of this post.</p>
<h2 id="heading-what-will-our-chrome-extension-look-like">What Will our Chrome Extension Look Like?</h2>
<p><img src="https://www.freecodecamp.org/news/content/images/2022/01/covid_report.gif" alt="Image" width="600" height="400" loading="lazy">
<em>Latest Covid Report of UK-Chrome Extension</em></p>
<p>As you can see, the above chrome extension displays the latest data on Coronavirus (COVID-19) in the UK. We will be looking into how to create this extension in this blog post.</p>
<p>Here, we will be using the <a target="_blank" href="https://api.coronavirus.data.gov.uk/v1/data">https://api.coronavirus.data.gov.uk/v1/data</a> API in order to fetch data. We will only display the latest record for the simplicity of this post.</p>
<p>The complete source code of this project can be found on <a target="_blank" href="https://github.com/SampurnaC/chrome_extension_fcc">GitHub</a>.</p>
<h2 id="heading-how-to-create-a-chrome-extension">How to Create a Chrome Extension</h2>
<p>First of all, we need to create an empty folder where we will add our HTML, CSS, and JavaScript files.</p>
<p>Inside the folder, let’s create an index.html file with this HTML boilerplate code:</p>
<pre><code class="lang-html"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>Covid-19 Stats- UK<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"utf-8"</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p>Now, let’s add a link to the Bootstrap CDN in the head tag. We will be using the <a target="_blank" href="https://getbootstrap.com/docs/5.0/getting-started/introduction/">Bootstrap framework</a> here so that we don't have to write some extra CSS in this example.</p>
<pre><code class="lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>Covid-19 Stats- UK<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"utf-8"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">link</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">"stylesheet"</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
</code></pre>
<p>In the demo, we saw that the records are displayed as a table. So now we need to work on creating a table.</p>
<pre><code class="lang-html"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>Covid-19 Stats- UK<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"utf-8"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">link</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">"stylesheet"</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"container mt-3"</span> <span class="hljs-attr">style</span>=<span class="hljs-string">"width: 450px;"</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">h2</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-center"</span>&gt;</span>Covid Latest Report-UK<span class="hljs-tag">&lt;/<span class="hljs-name">h2</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">table</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"table table-bordered"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">thead</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">tr</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">th</span>&gt;</span>Date<span class="hljs-tag">&lt;/<span class="hljs-name">th</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">th</span>&gt;</span>Country<span class="hljs-tag">&lt;/<span class="hljs-name">th</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">th</span>&gt;</span>Confirmed<span class="hljs-tag">&lt;/<span class="hljs-name">th</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">th</span>&gt;</span>Deaths<span class="hljs-tag">&lt;/<span class="hljs-name">th</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">tr</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">thead</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">tbody</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">tr</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">td</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"date"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">td</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">td</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"areaName"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">td</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">td</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"latestBy"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">td</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">td</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"deathNew"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">td</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">tr</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">tbody</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">table</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"script.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p>The code above creates a table with a width of <code>450px</code>. There are four different headings in a table: <code>Date</code>, <code>Country</code>, <code>Confirmed</code>, and <code>Deaths</code>. </p>
<p>Here, you can see that each table data <code>td</code> has been assigned different IDs. We will be using the value of these IDs in JavaScript to update the table data. Also, here we have loaded the JavaScript in the end after loading all the HTML content.</p>
<p>Now, since the table has been displayed, we need to work on writing JavaScript in order to fetch data from the API.</p>
<p>Let's create a <code>script.js</code> file and add the following code:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">fetchData</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">const</span> res=<span class="hljs-keyword">await</span> fetch (<span class="hljs-string">"https://api.coronavirus.data.gov.uk/v1/data"</span>);
    <span class="hljs-keyword">const</span> record=<span class="hljs-keyword">await</span> res.json();
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"date"</span>).innerHTML=record.data[<span class="hljs-number">0</span>].date;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"areaName"</span>).innerHTML=record.data[<span class="hljs-number">0</span>].areaName;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"latestBy"</span>).innerHTML=record.data[<span class="hljs-number">0</span>].latestBy;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"deathNew"</span>).innerHTML=record.data[<span class="hljs-number">0</span>].deathNew;
}
fetchData();
</code></pre>
<p>Now, let’s break down the above code:</p>
<ul>
<li>Here we are using the async function called <code>fetchData</code>.</li>
<li>The data is being fetched from the https://api.coronavirus.data.gov.uk/v1/data API.</li>
<li>The JSON data is stored in a variable called <code>record</code>.</li>
<li>The HTML content of td with ids <code>date</code>, <code>areaName</code>, <code>latestBy</code> and <code>deathNew</code> are updated by the corresponding values of API.</li>
</ul>
<p>If we check the browser, we will be able to see the following result.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2022/01/covid_browser.gif" alt="Image" width="600" height="400" loading="lazy">
<em>Latest Covid Report of UK - Browser Preview</em></p>
<p>The data is being fetched from the API and it keeps on updating as soon as the data in API changes.</p>
<h2 id="heading-manifestjson-file">Manifest.json File</h2>
<p>As we discussed earlier, building a Chrome extension is similar to building any web application. The only difference is that the Chrome extension requires a <code>manifest.json</code> file where we keep all the configurations.</p>
<p>The manifest.json file contains all the necessary information required to build the Chrome extension. It is the first file the extension checks and everything is loaded from this single file.</p>
<p>Now, let's create a <code>manifest.json</code> file in the root folder and add the following code:</p>
<pre><code class="lang-json">{
    <span class="hljs-attr">"name"</span>: <span class="hljs-string">"Covid-19 Stats UK"</span>,
    <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0.0"</span>,
    <span class="hljs-attr">"description"</span>: <span class="hljs-string">"latest covid data of UK"</span>,
    <span class="hljs-attr">"manifest_version"</span>: <span class="hljs-number">3</span>,
    <span class="hljs-attr">"author"</span>: <span class="hljs-string">"Sampurna Chapagain"</span>,
    <span class="hljs-attr">"action"</span>:{
        <span class="hljs-attr">"default_popup"</span>: <span class="hljs-string">"index.html"</span>,
        <span class="hljs-attr">"default_title"</span>: <span class="hljs-string">"Latest Covid Report"</span>
    }
}
</code></pre>
<p>Our <code>manifest.json</code> file contains the value of <code>name</code>, <code>version</code>, <code>description</code>, <code>manifest_version</code> (3 in this case, which is the latest manifest version), <code>author</code>, and <code>action</code> fields. In the action field, there's the value for <code>default_popup</code> which contains the path to the HTML file which is <code>index.html</code> in this example. </p>
<p>You can have a look <a target="_blank" href="https://developer.chrome.com/docs/extensions/mv3/manifest/">here</a> to see all configurations of a <code>manifest.json</code> file.</p>
<p>Now, since we've also added the manifest.json file we are ready to add this project as an extension in our Chrome browser.</p>
<p>For that, we need to go to <code>Select More Tools</code> and then choose <code>Extensions</code> from the browser menu as shown in the picture below:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2022/02/Untitled-design--1-.png" alt="Image" width="600" height="400" loading="lazy">
<em>Navigating to extensions in Chrome</em></p>
<p>After choosing Extensions, it redirects to the extensions page in Chrome. Make sure to enable the <code>Developer mode</code> here.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2022/02/Untitled-design--1--1.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p>Once that's done, you need to click the <code>Load unpacked</code> button which will allow us to load our project in the Chrome extension store.</p>
<p>Now, the extension is available in our Chrome extension store. You can also pin the extension in the browser as shown in the gif below:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2022/02/pin_extension.gif" alt="Image" width="600" height="400" loading="lazy">
<em>Pin extension to the browser</em></p>
<p>This extension works only in your browser. If you want to publish it on the Chrome Web Store, you can follow this <a target="_blank" href="https://developer.chrome.com/docs/webstore/register/">link</a>.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>If you have some HTML, CSS, and JavaScript knowledge, you can easily build Chrome extensions. I hope after reading this blog post, you will create some cool extensions.</p>
<p>Happy Coding!</p>
<p>You can find me on <a target="_blank" href="https://twitter.com/saam_codes">Twitter</a> for daily content related to Web Development.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Write Your Own Browser Extension [Example Project Included] ]]>
                </title>
                <description>
                    <![CDATA[ In this article we will talk about Browser extensions – what they are, how they work, and how you can build your own. We will finish by actually writing our own extension (Super Fun!) which allows us to copy any code snippet to our clipboard with a c... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/write-your-own-browser-extensions/</link>
                <guid isPermaLink="false">66baef94d453cb5eb795159f</guid>
                
                    <category>
                        <![CDATA[ Browsers ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abhilekh gautam ]]>
                </dc:creator>
                <pubDate>Tue, 02 Nov 2021 15:57:40 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2021/10/browsers.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article we will talk about Browser extensions – what they are, how they work, and how you can build your own.</p>
<p>We will finish by actually writing our own extension (Super Fun!) which allows us to copy any code snippet to our clipboard with a click of a single button.</p>
<p>To continue with this article:</p>
<ul>
<li><p>You need to have a basic understanding of JavaScript.</p>
</li>
<li><p>You need the Firefox browser (or any other browser will also work)</p>
</li>
</ul>
<h2 id="heading-what-is-a-browser-extension">What is a Browser Extension?</h2>
<p>A browser extension is something you add to your browser which enhances your browsing experience by extending the capacity of your browser.</p>
<p>As an example, think about an ad blocker which you might have installed on your device. This makes your browsing experience better by blocking ads when you surf the web.</p>
<h2 id="heading-how-to-write-your-own-basic-browser-extension">How to Write Your Own Basic Browser Extension</h2>
<p>Now let's start by writing a very basic extension.</p>
<p>To begin, we'll create a folder inside which we create a file named <code>manifest.json</code>.</p>
<h3 id="heading-what-is-a-manifest-file">What is a manifest file?</h3>
<p>A manifest file is a must have file in any browser extension. This file contains basic data about our extension like name, version, and so on.</p>
<p>Now inside the <code>manifest.json</code> file copy the following snippet:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"manifest_version"</span>:<span class="hljs-number">2</span>,
  <span class="hljs-attr">"version"</span>:<span class="hljs-string">"1.0"</span>,
  <span class="hljs-attr">"name"</span>:<span class="hljs-string">"Test"</span>,
}
</code></pre>
<p>[<strong>2025 Update</strong>: Chrome <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/manifest">currently only supports version 3</a> for manifest.json]</p>
<h3 id="heading-how-to-load-the-extension-file">How to load the extension file</h3>
<p>For Firefox users, follow these steps:</p>
<p>In the address bar, search for this:</p>
<pre><code class="lang-javascript">about:debugging#/runtime/<span class="hljs-built_in">this</span>-firefox
</code></pre>
<p>You will see an option to <em>Load Temporary Add-on</em>. Click on that option and choose the <code>manifest.json</code> file from the directory.</p>
<p>For Chrome users:</p>
<p>In the address bar search for this:</p>
<pre><code class="lang-javascript">chrome:<span class="hljs-comment">//extensions</span>
</code></pre>
<ul>
<li><p>Enable Developer Mode and switch into it.</p>
</li>
<li><p>Click the Load unpacked button and select the extension directory.</p>
</li>
</ul>
<p>Hurray! You've installed the extension successfully. But the extension doesn't do anything currently. Now let's add some functionality to our extension. To do this, we'll edit our <code>manifest.json</code> file like this:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"manifest_version"</span>:<span class="hljs-number">2</span>,
  <span class="hljs-attr">"version"</span>:<span class="hljs-string">"1.0"</span>,
  <span class="hljs-attr">"name"</span>:<span class="hljs-string">"Test"</span>,
  <span class="hljs-attr">"content_scripts"</span>:[
    {
     <span class="hljs-attr">"matches"</span>:[<span class="hljs-string">"&lt;all_urls&gt;"</span>],
     <span class="hljs-attr">"js"</span>:[<span class="hljs-string">"main.js"</span>]
    }
  ]
}
</code></pre>
<p>In the above code, we added a content script to <code>manifest.json</code>. Content scripts can manipulate the Document Object Model of the web page. We can inject JS (and CSS) into a web page using a content script.</p>
<p><code>"matches"</code> contains the list of domains and subdomains where the content script should be added and <code>js</code> is an array of the JS files to be loaded.</p>
<p>Now inside the same directory create a <code>main.js</code> file and add the following code:</p>
<pre><code class="lang-js">alert(<span class="hljs-string">"The test extension is up and running"</span>)
</code></pre>
<p>Now reload the extension and when you visit any <code>URLs</code> you will see an alert message.</p>
<p><strong>Don't forget to reload the extension anytime you edit the code.</strong></p>
<h2 id="heading-how-to-customize-your-browser-extension">How to Customize Your Browser Extension</h2>
<p>Now let's have some more fun with our extension.</p>
<p>What we are going to do now is create a web extension that changes all the images of a webpage we visit to an image we choose.</p>
<p>For this, just add any image to the current directory and change the <code>main.js</code> file to:</p>
<pre><code class="lang-js"><span class="hljs-built_in">console</span>.log(<span class="hljs-string">"The extension is up and running"</span>);

<span class="hljs-keyword">var</span> images = <span class="hljs-built_in">document</span>.getElementsByTagName(<span class="hljs-string">'img'</span>)

<span class="hljs-keyword">for</span> (elt <span class="hljs-keyword">of</span> images){
   elt.src = <span class="hljs-string">`<span class="hljs-subst">${browser.runtime.getURL(<span class="hljs-string">"pp.jpg"</span>)}</span>`</span>
   elt.alt = <span class="hljs-string">'an alt text'</span>
}
</code></pre>
<p>Let's see whats going on here:</p>
<pre><code class="lang-js"><span class="hljs-keyword">var</span> images = <span class="hljs-built_in">document</span>.getElementsByTagName(<span class="hljs-string">'img'</span>)
</code></pre>
<p>This line of code selects all the elements in the web page with the <code>img</code> tag .</p>
<p>Then we loop through the array images using a for loop where we change the <code>src</code> attribute of all the <code>img</code> elements to a URL with the help of the <code>runtime.getURL</code> function.</p>
<p>Here <code>pp.jpg</code> is the name of the image file in the current directory in my device.</p>
<p>We need to inform our content script about the <code>pp.jpg</code> file by editing the <code>manifest.json</code> file to:</p>
<pre><code class="lang-js">{
  <span class="hljs-string">"manifest_version"</span>:<span class="hljs-number">2</span>,
  <span class="hljs-string">"version"</span>:<span class="hljs-string">"1.0"</span>,
  <span class="hljs-string">"name"</span>:<span class="hljs-string">"Test"</span>,
  <span class="hljs-string">"content_scripts"</span>:[
   {
    <span class="hljs-string">"matches"</span>:[<span class="hljs-string">"&lt;all_urls&gt;"</span>],
    <span class="hljs-string">"js"</span>:[<span class="hljs-string">"main.js"</span>]
   }
  ],
  <span class="hljs-string">"web_accessible_resources"</span>: [
        <span class="hljs-string">"pp.jpg"</span>
  ]
}
</code></pre>
<p>Then just reload the extension and visit any URL you like. Now you should see all the images being changed to the image which is in your current working directory.</p>
<h3 id="heading-how-to-add-icons-to-your-extension">How to add icons to your extension</h3>
<p>Add the following code in the <code>manifest.json</code> file:</p>
<pre><code class="lang-json"><span class="hljs-string">"icons"</span>: {
  <span class="hljs-attr">"48"</span>: <span class="hljs-string">"icon-48.png"</span>,
  <span class="hljs-attr">"96"</span>: <span class="hljs-string">"icon-96.png"</span>
}
</code></pre>
<h3 id="heading-how-to-add-a-toolbar-button-to-your-extension">How to add a toolbar button to your extension</h3>
<p>Now we'll add a button located in the toolbar of your browser. Users can interact with the extension using this button.</p>
<p>To add a toolbar button, add the following lines to the <code>manifest.json</code> file:</p>
<pre><code class="lang-json"><span class="hljs-string">"browser_action"</span>:{
   <span class="hljs-attr">"default_icon"</span>:{
     <span class="hljs-attr">"19"</span>:<span class="hljs-string">"icon-19.png"</span>,
     <span class="hljs-attr">"38"</span>:<span class="hljs-string">"icon-38.png"</span>
   }
  }
</code></pre>
<p>All the image files should be present in your current directory.</p>
<p>Now, if we reload the extension we should see an icon for our extension in the toolbar of our browser.</p>
<h3 id="heading-how-to-add-listening-events-for-the-toolbar-button">How to add listening events for the toolbar button</h3>
<p>Maybe we want to do something when a user clicks the button – let's say we want to open a new tab every time the button is clicked.</p>
<p>For this, we'll again add the following to the <code>manifest.json</code> file:</p>
<pre><code class="lang-json"><span class="hljs-string">"background"</span>:{
        <span class="hljs-attr">"scripts"</span>:[<span class="hljs-string">"background.js"</span>]
  },
  <span class="hljs-string">"permissions"</span>:[
      <span class="hljs-string">"tabs"</span>
  ]
</code></pre>
<p>Then we'll create a new file named <code>background.js</code> in the current working directory and add the following lines in the file:</p>
<pre><code class="lang-js"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">openTab</span>(<span class="hljs-params"></span>)</span>{

    <span class="hljs-keyword">var</span> newTab = browser.tabs.create({
        <span class="hljs-attr">url</span>:<span class="hljs-string">'https://twitter.com/abhilekh_gautam'</span>,
        <span class="hljs-attr">active</span>:<span class="hljs-literal">true</span>
    })
}

browser.browserAction.onClicked.addListener(openTab)
</code></pre>
<p>Now reload the extension!</p>
<p>Whenever someone clicks the button, it calls the <code>openTab</code> function which opens a new tab with the URL that links to my twitter profile. Also, the active key, when set to true, makes the newly created tab the current tab.</p>
<p>Note that you can use APIs provided by browsers in the background script. For more about APIs refer to the following article: <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API">Javacript APIs</a>.</p>
<p>Now that we've learned some of the basics of browser extensions, let's create an extension that we as developers can use in our daily lives.</p>
<h2 id="heading-final-project">Final Project</h2>
<p>Alright, now we're going to write something that will be useful for us in daily life. We'll create an extension that allows you to copy code snippets from StackOverflow with a single click. So our extension will add a <code>Copy</code> button to the webpage which copies the code to our clipboard.</p>
<h3 id="heading-demo">Demo</h3>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/10/demo.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p>First we'll create a new folder/directory, inside which we'll add a <code>manifest.json</code> file.</p>
<p>Add the following code to the file:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"manifest_version"</span>:<span class="hljs-number">2</span>,
  <span class="hljs-attr">"version"</span>:<span class="hljs-string">"1.0"</span>,
  <span class="hljs-attr">"name"</span>:<span class="hljs-string">"copy code"</span>,
  <span class="hljs-attr">"content_scripts"</span>:[
    {
     <span class="hljs-attr">"matches"</span>:[<span class="hljs-string">"*://*.stackoverflow.com/*"</span>],
     <span class="hljs-attr">"js"</span>:[<span class="hljs-string">"main.js"</span>]
    }
  ]
}
</code></pre>
<p>Look at the <code>matches</code> inside the <code>content script</code> – the extension will only work for StackOverflow's domain and subdomain.</p>
<p>Now create another JavaScript file called <code>main.js</code> in the same directory and add the following lines of code:</p>
<pre><code class="lang-js"><span class="hljs-keyword">var</span> arr =<span class="hljs-built_in">document</span>.getElementsByClassName(<span class="hljs-string">"s-code-block"</span>)

<span class="hljs-keyword">for</span>(<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span> ; i &lt; arr.length ; i++){
 <span class="hljs-keyword">var</span> btn = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">"button"</span>)
 btn.classList.add(<span class="hljs-string">"copy_code_button"</span>)
 btn.appendChild(<span class="hljs-built_in">document</span>.createTextNode(<span class="hljs-string">"Copy"</span>))
 arr[i].appendChild(btn)
 <span class="hljs-comment">//styling the button</span>
 btn.style.position = <span class="hljs-string">"relative"</span>

 <span class="hljs-keyword">if</span>(arr[i].scrollWidth === arr[i].offsetWidth &amp;&amp; arr[i].scrollHeight === arr[i].offsetHeight)
  btn.style.left = <span class="hljs-string">`<span class="hljs-subst">${arr[i].offsetWidth - <span class="hljs-number">70</span>}</span>px`</span>

  <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span>(arr[i].scrollWidth != arr[i].offsetWidth &amp;&amp; arr[i].scrollHeight === arr[i].offsetWidth)
   btn.style.left = <span class="hljs-string">`<span class="hljs-subst">${arr[i].offsetWidth - <span class="hljs-number">200</span>}</span>px`</span>
 <span class="hljs-keyword">else</span> 
   btn.style.left = <span class="hljs-string">`<span class="hljs-subst">${arr[i].offsetWidth - <span class="hljs-number">150</span>}</span>px`</span>

 <span class="hljs-keyword">if</span>(arr[i].scrollHeight === arr[i].offsetHeight)
   btn.style.bottom = <span class="hljs-string">`<span class="hljs-subst">${arr[i].offsetHeight - <span class="hljs-number">50</span>}</span>px`</span>

 <span class="hljs-keyword">else</span>
   btn.style.bottom = <span class="hljs-string">`<span class="hljs-subst">${arr[i].scrollHeight - <span class="hljs-number">50</span>}</span>px`</span>
 <span class="hljs-comment">//end of styling the button</span>

   <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Appended"</span>)
}
</code></pre>
<p>First of all, I selected all the elements with the class name <code>s-code-block</code> – but why? This is because when I inspected StackOverflow's site I found that all the code snippets were kept in a class with that name.</p>
<p>And then we loop through all those elements and append a button in those elements. Finally, we just position and style the button properly (the styling's not perfect yet – this is just a start).</p>
<p>When we load the extension using the process we went through above and visit StackOverflow, we should see a copy button.</p>
<h3 id="heading-how-to-add-functionality-to-the-button">How to add functionality to the button</h3>
<p>Now, when the button is clicked we want the entire snippet to be copied to our clip board. To do this, add the following line of code to the <code>main.js</code> file:</p>
<pre><code class="lang-js"><span class="hljs-keyword">var</span> button = <span class="hljs-built_in">document</span>.querySelectorAll(<span class="hljs-string">".copy_code_button"</span>)
 button.forEach(<span class="hljs-function">(<span class="hljs-params">elm</span>)=&gt;</span>{
  elm.addEventListener(<span class="hljs-string">'click'</span>,<span class="hljs-function">(<span class="hljs-params">e</span>)=&gt;</span>{
    navigator.clipboard.writeText(elm.parentNode.childNodes[<span class="hljs-number">0</span>].innerText)
    alert(<span class="hljs-string">"Copied to Clipboard"</span>)
  })
 })
</code></pre>
<p>First of all, we select all the buttons we have added to the site using <code>querySelectorAll</code>. Then we listen for the click event whenever the button is clicked.</p>
<pre><code class="lang-js">navigator.clipboard.writeText(elm.parentNode.childNodes[<span class="hljs-number">0</span>].innerText)
</code></pre>
<p>The above line of code copies the code to our clipboard. Whenever a snippet is copied we alert the user with the message <code>Copied to clipboard</code> and we are done.</p>
<h2 id="heading-final-words">Final Words</h2>
<p>Web Extensions can be useful in various way and I hope with the help of this article you will be able to write your own extensions.</p>
<p>All the code can be found in <a target="_blank" href="https://github.com/Abhilekhgautam/Copy-Code">this GitHub</a> repository. Don't forget to give a pull request anytime you come up with some good styling or a new feature like clipboard history and others.</p>
<p><strong>Happy Coding!</strong></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Chrome Extension Tutorial: How to Pass Messages from a Page's Context ]]>
                </title>
                <description>
                    <![CDATA[ By Tarique Ejaz In the world of web development, Chrome extensions are a pretty handy set of tools to have around.  Whether you use them to add headers to simple requests or to scrape important data from the DOM, extensions help provide extra functio... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/chrome-extension-message-passing-essentials/</link>
                <guid isPermaLink="false">66d4614a246e57ac83a2c7cf</guid>
                
                    <category>
                        <![CDATA[ Google Chrome ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ messaging ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ freeCodeCamp ]]>
                </dc:creator>
                <pubDate>Wed, 24 Feb 2021 02:14:59 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2021/02/DDF5E684-AEF5-45E6-9421-E5D4360E9A85-1.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>By Tarique Ejaz</p>
<p>In the world of web development, Chrome extensions are a pretty handy set of tools to have around. </p>
<p>Whether you use them to add headers to simple requests or to scrape important data from the DOM, extensions help provide extra functionality that makes life easier.</p>
<p>I started playing around with developing a Chrome extension for a use-case I had in mind at work. It was then that I stumbled upon the way we pass around certain data from a web page to an extension. And the lack of a simplified guide made me write this article. </p>
<p>We do have the <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/">Chrome API documentation</a>, and it is indeed very thorough. But I consider myself to be more of a visual learner, and being able to visualize how we pass messages between the extension scripts helped simplify the overall development.</p>
<blockquote>
<p><strong>Note:</strong> This article makes use of Manifest V2 instead of V3. The major difference between the two is the usage of service workers in V3 instead of background pages and related actions. </p>
</blockquote>
<h2 id="heading-message-passing-interaction-between-scripts">Message Passing: Interaction Between Scripts</h2>
<p>An extension, as the name suggests, is like a layer on top of the existing webpage you're trying to access. The browser acts as the container.</p>
<p>It mainly comprises the following scripts:</p>
<ul>
<li><strong>Popup Script</strong> - Local JavaScript file for the extension DOM</li>
<li><strong>Background Script</strong> - Provides persistence and handles background events</li>
<li><strong>Content Script</strong> - Scripts that run in isolation in the context of the web page</li>
<li><strong>Injected Script</strong> - Scripts that are programmatically injected into the web page </li>
</ul>
<p>Normally, if you have to merely deal with the DOM content, then the way the extension is developed is relatively straightforward. </p>
<p>The raw HTML is already available to the content script and all you need to do is pass it to the popup script. </p>
<p>However, if you need to access the page's variables and functions, the process gets a little tricky.</p>
<p>The variables and functions available in the page context, say in the <code>window</code> object, are not accessible to the content scripts since they tend to run in a special JavaScript environment. They have access to only the DOM of the page but not the variables and functions. </p>
<p>To access a page's variables and functions, we inject scripts by appending them to the DOM. This makes the browser assume that it is run in the context of the web page. This in turn provides the injected script access to the local variables and functions.</p>
<p>Since Chrome extensions are event-driven because of their architecture, once the injected scripts have access to the page's variables and functions, they can pass it to the content script. </p>
<p>The content script then passes the these objects to the background page. </p>
<p>And finally, the popup script is able to call onto the background page using the Extension API and pass it to the Extension DOM. </p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/02/flowchart.png" alt="Image" width="600" height="400" loading="lazy">
<em>Message Passing Overview</em></p>
<p>Now, we will build a simple Performance Watcher extension that reads the performance data from the global window object of a page and maps the essential metrics for the user to see. Let's get into the code then.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/02/extn.PNG" alt="Image" width="600" height="400" loading="lazy"></p>
<h2 id="heading-enough-talk-show-me-the-code">Enough Talk, Show Me The Code</h2>
<p>You can find the complete code repository for the project <a target="_blank" href="https://github.com/tejazz/article-snippets/tree/master/chrome-extn-message-passing">here</a>. Let's quickly run through the primary files and the important functionalities they offer.</p>
<h3 id="heading-the-manifest-file">The Manifest File</h3>
<p>Every Chrome Extension needs a <code>manifest</code> file. It is basically a JSON-formatted file that provides a set of metadata so the browser can recognize the permissions that need to be granted and the likely operational reach of the extension. </p>
<p>Here is the manifest used for our application.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/02/manifest.png" alt="Image" width="600" height="400" loading="lazy">
<em>manifest.json: metadata for your extension</em></p>
<p>Some of the important properties we need to focus on are the following:</p>
<ul>
<li><code>background</code> - Takes an array of scripts that would be run in the background page. </li>
<li><code>content-scripts</code> - Includes an array of content scripts we wish to run as part of the web page's context. </li>
<li><code>web_accessible_resources</code> - An array of packaged resources expected to be used in a web page's context. For example, an image we intend to embed in a page or a custom script we want to inject.</li>
<li><code>permissions</code> - Allows your extension to gain access to certain Chrome APIs like <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/tabs/#type-Tab">tabs</a> in this case. </li>
</ul>
<h3 id="heading-the-content-script">The Content Script</h3>
<p>Content Scripts have easy access to the DOM of the web page. We make use of the content script to append our custom script – <code>inject-script.js</code> – into the DOM.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/02/content-inject.png" alt="Image" width="600" height="400" loading="lazy">
<em>content-script.js: inject custom script into the DOM</em></p>
<p>The content script also simultaneously continues to listen for any message being sent upstream from the custom script. </p>
<p>As soon as we get a message from the injected script, we run a quick check on the data received and verify whether our extension is installed. Once done, we simply use Chrome's <a target="_blank" href="https://developer.chrome.com/docs/extensions/reference/runtime/">Runtime API</a> to send the data received forward to the background page. </p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/02/content-send.png" alt="Image" width="600" height="400" loading="lazy">
<em>content-script.js: send the required data to the background page</em></p>
<h3 id="heading-the-injected-script">The Injected Script</h3>
<p>The custom script can access global variables and functions like the <code>window</code> object. We map only the properties we require. </p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/02/inject-action.png" alt="Image" width="600" height="400" loading="lazy">
<em>inject-script.js: procure required object from the page's JS context</em></p>
<p>The message from the custom script is communicated safely to the content script using the <code>[window.postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)</code> function. In this case, a <code>setInterval</code> function is used to dynamically update the properties we are observing.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/02/inject-send.png" alt="Image" width="600" height="400" loading="lazy">
<em>inject-script.js: send the gathered data to the content-script</em></p>
<h3 id="heading-the-background-script">The Background Script</h3>
<p>The background script listens for any message transmitted by the content script using the Runtime API. The <code>window</code> object of the background page is then updated with the <code>tab.id</code> acting as the identifier. </p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/02/background.png" alt="Image" width="600" height="400" loading="lazy">
<em>background.js: listen for incoming message from the content-script</em></p>
<h3 id="heading-the-popup-script">The Popup Script</h3>
<p>The popup script is where we finally read the data we had procured from our custom script. It is also the place where we code any necessary JavaScript operations.</p>
<p>The background page is retrieved using the <code>getBackgroundPage</code> method of the Extension API. The active tab's id is queried using the <code>tabs.query</code> method of the Tabs API in order to correctly extract the relevant data.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/02/popup.png" alt="Image" width="600" height="400" loading="lazy">
<em>popup.js: reading the global object stored in the background page context</em></p>
<p>In this way, we are able to finally receive and map the data we need – <code>performance</code> in our case – efficiently in our extension.</p>
<p>The UI styling and other cosmetic code are available in the repository, for further reference.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Message passing is an essential concept when it comes to developing a Chrome extension. This is just one of the multiple ways in which you can communicate between scripts. </p>
<p>I spent a few hours in order to figure out how it would work for my use case. Hopefully, this simple walkthrough and the visual representation saves you some time. </p>
<p>I would suggest playing around with the code for a bit. If you have any questions, feel free to reach out to me on <code>[LinkedIn](https://www.linkedin.com/in/tarique-ejaz/)</code>.</p>
<p>In the meantime, keep coding.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Make a Chrome Extension – a Browser Plugin Development Tutorial ]]>
                </title>
                <description>
                    <![CDATA[ By Marouane Rassili Building a Chrome extension can be overwhelming. It's different than building a web app in that you don't want to put too much JavaScript overhead on the browser since your extension will be run along with the website you're visit... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/chrome-extension-with-parcel-tailwind/</link>
                <guid isPermaLink="false">66d4604037bd2215d1e24598</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ plugins ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tailwind ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ freeCodeCamp ]]>
                </dc:creator>
                <pubDate>Thu, 25 Jun 2020 19:04:48 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2020/06/emile-perron-xrVDYZRGdw4-unsplash.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>By Marouane Rassili</p>
<p>Building a Chrome extension can be overwhelming. It's different than building a web app in that you don't want to put too much JavaScript overhead on the browser since your extension will be run along with the website you're visiting. You also don't usually get the benefit of bundling and debugging that are available with today's bundlers and frameworks.</p>
<p>When I decided to build a Chrome extension, I found that the number of blog posts and articles about building one is quite small. And there's even less information when you want to use one of the newer technologies like TailwindCSS.</p>
<p>In this tutorial we will discover how to build a Chrome extension using Parcel.js for bundling and watching and TailwindCSS for styling. We will also talk about how to separate your styling from the website to avoid colliding CSS – but more on that later.</p>
<p><strong>There are a few types of Chrome extensions worth mentioning:</strong></p>
<ul>
<li><strong>Content scripts</strong>: The first type of Chrome extension is the most common. It runs in the context of a web page and can be used to modify its content. This is the type of extension we'll be creating.</li>
<li><strong>Popup</strong>: Popup-based extensions use the extension icon to the right of the address bar to open a popup which can contain any HTML content that you like.</li>
<li><strong>Options UI</strong>: You guessed it! This is a UI for customizing options as an extension. It's accessible by right clicking the extension icon and selecting "options" or by navigating to the extension's page from the Chrome extensions list <code>chrome://extensions</code></li>
<li><strong>DevTools Extension</strong>: "A DevTools extension adds functionality to the Chrome DevTools. It can add new UI panels and sidebars, interact with the inspected page, get information about network requests, and more". -<a target="_blank" href="https://developer.chrome.com/extensions/devtools">Google Chrome documentation</a></li>
</ul>
<p>In this tutorial we will build a Chrome extension using only content scripts by displaying content on the web page and interacting with the DOM.</p>
<h2 id="heading-how-to-bundle-your-chrome-extension-using-parceljs-v2">How to bundle your Chrome Extension using Parcel.js V2</h2>
<p>Parcel.js is a zero-configuration web application bundler. It can use any kind of file as an entry point. It's simple to use and will work for any type of app including Chrome Extensions.</p>
<p>First create a new folder called <code>demo-extension</code> (make sure you have <code>yarn</code> or <code>npm</code> installed, I am going to use <code>yarn</code> for this post):</p>
<p><code>$ mkdir demo-extension &amp;&amp; cd demo-extension &amp;&amp; yarn init -y</code></p>
<p>Next we will install Parcel as a local dependency:</p>
<p><code>$ yarn add -D parcel@next</code></p>
<p>Now create a new directory called <code>src</code>:</p>
<p><code>$ mkdir src</code></p>
<h3 id="heading-adding-a-manifest-file">Adding a manifest file</h3>
<p>Every browser extension needs a manifest file. This is where we define the version and meta data about our extension, but also the scripts that are used (content, background, popup, .etc) and permissions if any. </p>
<p>You can find the full schema in Chrome's documentation: https://developer.chrome.com/extensions/manifest</p>
<p>Let's go ahead and add a new file in <code>src</code> called <code>manifest.json</code> with this content:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"Demo extension"</span>,
  <span class="hljs-attr">"description"</span>: <span class="hljs-string">"An extension built with Parcel and TailwindCSS."</span>,
  <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0"</span>,
  <span class="hljs-attr">"manifest_version"</span>: <span class="hljs-number">2</span>,
}
</code></pre>
<p>Now, before we go into more detail about how chrome extensions work and the kind of stuff you can make with them, we are going to set up <a target="_blank" href="https://tailwindcss.com/">TailwindCSS</a></p>
<h3 id="heading-how-to-use-tailwindcss-with-your-chrome-extension">How to use TailwindCSS with your Chrome Extension</h3>
<p>TailwindCSS is a CSS-framework that uses lower-level utility classes to create reusable but also customizable visual UI components.</p>
<p>Tailwind can be installed in two ways, but the most common way to use it is via its NPM package: </p>
<p><code>$ yarn add tailwindcss</code></p>
<p>Also, go ahead and add <code>autoprefixer</code> and <code>postcss-import</code>:</p>
<p><code>$ yarn add -D autoprefixer postcss-import</code></p>
<p>You need these to add vendor prefixes to your styles and to be able to write syntax like <code>@import "tailwindcss/base"</code> to import Tailwind files directly from <code>node_modules</code>, respectively.</p>
<p>Now that we have it installed, let's make a new file inside our root directory and call it <code>postcss.config.js</code>. This is the configuration file for PostCSS and it will contain, for now, these lines:</p>
<pre><code class="lang-js"><span class="hljs-built_in">module</span>.exports = {
  <span class="hljs-attr">plugins</span>: [
    <span class="hljs-built_in">require</span>(<span class="hljs-string">"postcss-import"</span>),
    <span class="hljs-built_in">require</span>(<span class="hljs-string">"tailwindcss"</span>),
    <span class="hljs-built_in">require</span>(<span class="hljs-string">"autoprefixer"</span>),
  ],
};
</code></pre>
<p><em>Order of plugins matters here!</em></p>
<p>That's it! That's all you need to get started using TailwindCSS within your Chrome extension.</p>
<p>Create a file called <code>style.css</code> inside your <code>src</code> folder and include Tailwind files:</p>
<pre><code class="lang-css"><span class="hljs-keyword">@import</span> <span class="hljs-string">"tailwindcss/base"</span>;
<span class="hljs-keyword">@import</span> <span class="hljs-string">"tailwindcss/utilities"</span>;
</code></pre>
<h3 id="heading-remove-unused-css-using-purgecss">Remove unused CSS using PurgeCSS</h3>
<p>Let's also make sure we only import the styles we use by enabling Tailwind's purging capability.</p>
<p>Create a new Tailwind configuration file by running: </p>
<p><code>$ npx tailwindcss init</code>: this will create a new <code>tailwind.config.js</code> file.</p>
<p>To remove unused CSS, we're going to add our source files to the purge field like this:</p>
<pre><code class="lang-js"><span class="hljs-built_in">module</span>.exports = {
  <span class="hljs-attr">purge</span>: [
    <span class="hljs-string">'./src/**/*.js'</span>, 👈
  ],
  <span class="hljs-attr">theme</span>: {},
  <span class="hljs-attr">variants</span>: {},
  <span class="hljs-attr">plugins</span>: [],
}
</code></pre>
<p>Now our CSS will be purged and unused styles will be removed when you build for production.</p>
<h3 id="heading-how-to-enable-hot-reloading-within-your-chrome-extension">How to enable Hot Reloading within your Chrome Extension</h3>
<p>One more thing before adding some content to our extension: let's enable hot reloading.</p>
<p>Chrome doesn't reload the source files when you make new changes – you need to hit the "Reload" button on the extensions page. Fortunately, there's an NPM package that does hot reloading for us.</p>
<p><code>$ yarn add crx-hotreload</code></p>
<p>In order to use it, we'll create a new file called <code>background.js</code> inside our <code>src</code> folder and import <code>crx-hotreload</code></p>
<pre><code class="lang-js"><span class="hljs-keyword">import</span> <span class="hljs-string">"crx-hotreload"</span>;
</code></pre>
<p>Finally point to <code>background.js</code> in <code>manifest.json</code> so it can be served with our extension (hot reloading is disabled in production by default):</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"Demo extension"</span>,
  <span class="hljs-attr">"description"</span>: <span class="hljs-string">"An extension built with Parcel and TailwindCSS."</span>,
  <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0"</span>,
  <span class="hljs-attr">"manifest_version"</span>: <span class="hljs-number">2</span>,
  <span class="hljs-attr">"background"</span>: { 👈
    <span class="hljs-attr">"scripts"</span>: [<span class="hljs-string">"background.js"</span>]
  },
}
</code></pre>
<p>Enough with configuration. Let's build a small script form within our extension.</p>
<h4 id="heading-types-of-scripts-in-a-chrome-extension">Types of scripts in a Chrome extension</h4>
<p>As mentioned in the beginning of this post, in Chrome extensions there a few types of scripts you can use.</p>
<p><em>Content scripts</em> are scripts that run in the context of the visited web page. You can run any JavaScript code that is otherwise available in any regular web page (including accessing/manipulating the DOM). </p>
<p>A background script, on the other hand, is where you can react to browser events, and it has access to the Chrome extension APIs.</p>
<h3 id="heading-adding-a-content-script">Adding a content script</h3>
<p>Create a new file called <code>content-script.js</code> under the <code>src</code> folder.</p>
<p>Let's add this HTML form to our newly created file:</p>
<pre><code class="lang-js"><span class="hljs-keyword">import</span> cssText <span class="hljs-keyword">from</span> <span class="hljs-string">"bundle-text:../dist/style.css"</span>;

<span class="hljs-keyword">const</span> html =
<span class="hljs-string">`
&lt;style&gt;<span class="hljs-subst">${cssText}</span>&lt;/style&gt;

&lt;section id="popup" class="font-sans text-black z-50 w-full fixed top-0 right-0 shadow-xl new-event-form bg-white max-w-sm border-2 border-black p-5 rounded-lg border-b-6"&gt;
  &lt;header class="flex mb-5 pl-1 items-center justify-between"&gt;
    &lt;span class="text-2xl text-black font-extrabold"&gt;New event!&lt;/span&gt;
  &lt;/header&gt;
  &lt;main class="event-name-input mb-6"&gt;
    &lt;div class="mb-6"&gt;
      &lt;label
        for="event-name"
        class="font-bold pl-1 block mb-1 text-black text-xl"
        &gt;
      Event name
      &lt;/label&gt;
      &lt;div class="duration-400 flex bg-white border-black border-2 rounded-lg py-4 px-4 text-black text-xl focus-within:shadow-outline"&gt;
        &lt;input
          id="event-name"
          name="event-name"
          type="text"
          placeholder="web.dev LIVE"
          class="font-medium w-full focus:outline-none"
          /&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;/div&gt;
    &lt;div class="mb-6"&gt;
      &lt;label
        for="event-date"
        class="font-bold pl-1 block mb-1 text-black text-xl"
        &gt;
      Date
      &lt;/label&gt;
      &lt;div class="event-date-input duration-400 border-black flex bg-white border-2 rounded-lg py-4 px-4  text-xl focus-within:shadow-outline"&gt;
        &lt;input
          id="event-date"
          name="event-date"
          type="date"
          class="font-medium w-full focus:outline-none"
          /&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=" mb-8"&gt;
    &lt;label
      for="event-time-input"
      class="font-bold pl-1 block mb-1  text-xl"
      &gt;
    Time
    &lt;/label&gt;
    &lt;div class="inline-flex items-center"&gt;
      &lt;input
        id="event-time-input"
        type="time"
        value="17:30"
        class="border-black mr-4 lowercase duration-400 w-auto bg-white text-xl border-2  rounded-lg px-4 py-4 focus:outline-none focus:shadow-outline"
        /&gt;
      &lt;div class="inline-flex flex-col"&gt;
        &lt;span class="text-xl font-bold"&gt;Casablanca&lt;/span&gt;
        &lt;span class="text-base font-normal"&gt;Africa&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/main&gt;
  &lt;footer&gt;
  &lt;button 
    class="duration-400 bg-green-400 text-xl py-4 w-full rounded-lg border-2 border-b-6 leading-7 font-extrabold border-black focus:outline-none focus:shadow-outline"
    &gt;
  Save
  &lt;/button&gt;
  &lt;/footer
&lt;/section&gt;
`</span>

<span class="hljs-keyword">const</span> shadowHost = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">"div"</span>);
<span class="hljs-built_in">document</span>.body.insertAdjacentElement(<span class="hljs-string">"beforebegin"</span>, shadowHost);
<span class="hljs-keyword">const</span> shadowRoot = shadowHost.attachShadow({ <span class="hljs-attr">mode</span>: <span class="hljs-string">'open'</span> });

shadowRoot.innerHTML = html
</code></pre>
<p>Styling a browser extension is not as straightforward as you may think because you need to make sure that the website styles are not affected by your own styles. </p>
<p>In order to separate them, we are going to use something called the <em>Shadow DOM</em>. The Shadow DOM is a powerful encapsulation technique for styles. This means that styling is scoped to the Shadow tree. Therefore, nothing is leaked out to the visited web page. Also outside styles do not override the Shadow DOM's content, although CSS variables can still be accessible.</p>
<p>A <em>shadow host</em> is any DOM element we would like to attach our Shadow tree to. A <em>Shadow Root</em> is what is returned from <code>attachShadow</code> and its content is what gets rendered.</p>
<p><strong>Beware</strong>, the only way to style the content of a Shadow tree is by inlining styles. Parcel V2 has a new built-in feature where you can import the content of one bundle, and use it as compiled text inside your JavaScript files. Then Parcel will replace it at the time of packaging.</p>
<p>We did exactly that with our <code>style.css</code> bundle. Now we can inline the CSS automatically to the Shadow DOM at build time.</p>
<p>Now we have to tell the browser about this new file, let's go ahead and include it by adding these lines to our manifest:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"Demo extension"</span>,
  <span class="hljs-attr">"description"</span>: <span class="hljs-string">"An extension built with Parcel and TailwindCSS."</span>,
  <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0"</span>,
  <span class="hljs-attr">"manifest_version"</span>: <span class="hljs-number">2</span>,
  <span class="hljs-attr">"background"</span>: {
    <span class="hljs-attr">"scripts"</span>: [<span class="hljs-string">"background.js"</span>]
  },
  👇
  <span class="hljs-attr">"content_scripts"</span>: [
    {
      <span class="hljs-attr">"matches"</span>: [<span class="hljs-string">"&lt;all_urls&gt;"</span>],
      <span class="hljs-attr">"js"</span>: [<span class="hljs-string">"content-script.js"</span>],
    }
  ]
}
</code></pre>
<p>In order to serve our extension, we are going to add a few scripts to our <code>package.json</code>:</p>
<pre><code class="lang-json">  <span class="hljs-string">"scripts"</span>: {
    <span class="hljs-attr">"prebuild"</span>: <span class="hljs-string">"rm -rf dist .cache .parcel-cache"</span>,
    <span class="hljs-attr">"build:tailwind"</span>: <span class="hljs-string">"tailwindcss build src/style.css -c ./tailwind.config.js -o dist/style.css"</span>,
    <span class="hljs-attr">"watch"</span>: <span class="hljs-string">"NODE_ENV=development yarn build:tailwind &amp;&amp; cp src/manifest.json dist/ &amp;&amp; parcel watch --no-hmr src/{background.js,content-script.js}"</span>,
    <span class="hljs-attr">"build"</span>: <span class="hljs-string">"NODE_ENV=production yarn build:tailwind &amp;&amp; cp src/manifest.json dist/ &amp;&amp; parcel build src/{background.js,content-script.js}"</span>,
  }
</code></pre>
<p>Finally you can run <code>yarn watch</code>, go to <code>chrome://extensions</code>, and make sure <em>Developer Mode</em> is enabled on the top right of the page. Click on "Load Unpacked" and select the <code>dist</code> folder under <code>demo-extension</code>.</p>
<ul>
<li><em>If you get this error: <code>Error: Bundles must have unique filePaths</code> you can simply fix it by removing the <code>main</code> field in your <code>package.json</code></em></li>
</ul>
<h2 id="heading-how-to-publish-your-extension-to-the-google-chrome-web-store">How to publish your extension to the Google Chrome Web Store</h2>
<p>Before going further into this, let's add a new NPM script that will help us compress the extension files as required by Chrome.</p>
<pre><code class="lang-json">  <span class="hljs-string">"scripts"</span>: {
    <span class="hljs-attr">"prebuild"</span>: <span class="hljs-string">"rm -rf dist .cache"</span>,
    <span class="hljs-attr">"build:tailwind"</span>: <span class="hljs-string">"tailwindcss build src/style.css -c ./tailwind.config.js -o dist/style.css"</span>,
    <span class="hljs-attr">"watch"</span>: <span class="hljs-string">"NODE_ENV=development yarn build:tailwind &amp;&amp; cp src/manifest.json dist/ &amp;&amp; parcel watch --no-hmr src/{background.js,content-script.js}"</span>,
    <span class="hljs-attr">"build"</span>: <span class="hljs-string">"NODE_ENV=production yarn build:tailwind &amp;&amp; cp src/manifest.json dist/ &amp;&amp; parcel build src/{background.js,content-script.js}"</span>,
    <span class="hljs-attr">"zip"</span>: <span class="hljs-string">"zip -r chrome-extension.zip ./dist"</span> 👈
  }
</code></pre>
<p>If you haven't installed <code>zip</code> yet, please do so:</p>
<ul>
<li>MacOS: <code>brew install zip</code></li>
<li>Linux: <code>sudo apt install zip</code></li>
<li>For Windows, you can use the powershell command <code>Compress-Archive</code> similarly: <code>powershell Compress-Archive -Path .\\dist\\ -Destination .\\chrome-extension.zip</code></li>
</ul>
<p>Now all you have to do is head to <a target="_blank" href="https://chrome.google.com/webstore/devconsole/register">Chrome Web Store Developer Dashboard</a> to set up your account and publish your extension ?</p>
<ul>
<li><em>You can find the complete demo for this tutorial hosted on my GitHub account <a target="_blank" href="https://github.com/mrassili/extension-demo">here</a></em></li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In the end, Chrome extensions are not that different from web apps. Today you learned how to develop an extension using the latest technologies and practices in web development. </p>
<p>Hopefully this tutorial helped you speed up your extension development a little bit!</p>
<p>If you found this helpful, please Tweet about it and follow me at <a target="_blank" href="https://twitter.com/marouanerassili">@marouanerassili</a>.</p>
<p>Thank you!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to get easy-to-read JSON trees with this free Chrome Extension (or Firefox Plugin) ]]>
                </title>
                <description>
                    <![CDATA[ JSON is a very popular file format. Sometimes we may have a JSON object inside a browser tab that we need to read and this can be difficult. We may need to go and search for an online tool that turns it into an easy-to-read format so we can understan... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/untitleeasy-to-read-json-with-this-chrome-firefox-extensiond/</link>
                <guid isPermaLink="false">66d45f0b182810487e0ce194</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ json ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Fatos Morina ]]>
                </dc:creator>
                <pubDate>Wed, 14 Aug 2019 11:29:54 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2019/08/0_9FUbCPtz2dGrBmIi.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p><a target="_blank" href="https://www.json.org/">JSON</a> is a very popular file format. Sometimes we may have a JSON object inside a browser tab that we need to read and this can be difficult.</p>
<p>We may need to go and search for an online tool that turns it into an easy-to-read format so we can understand it.</p>
<p>Now, here is a Chrome and Firefox extension that does the formatting and makes your JSONs instantly pretty inside your browser, without having to perform many unnecessary steps.</p>
<p>It comes with support for JSON and JSONP and highlights the syntax so that you can differentiate different attributes and values accordingly. It also comes with the option to collapse nodes, clickable URLs that you can open in new tabs, and you see the raw, unformatted JSON.</p>
<p>It works with any JSON page, regardless of the URL you opened. It also works with local files, after you enable it in <code>chrome://extensions</code>. You can inspect the JSON by typing <code>json</code> into the console.</p>
<p>You can install the extension by going <a target="_blank" href="https://chrome.google.com/webstore/detail/json-formatter/bcjindcccaagfpapjjmafapmmgkkhgoa?hl=en">here for Chrome</a> and <a target="_blank" href="https://addons.mozilla.org/en-US/firefox/addon/basic-json-formatter/">here for Firefox</a> and then test it, for example, by visiting this <a target="_blank" href="https://efa.mvv-muenchen.de/ng/XSLT_DM_REQUEST?outputFormat=JSON&amp;language=en&amp;stateless=1&amp;coordOutputFormat=MRCV&amp;useRealtime=1&amp;zope_command=enquiry&amp;type_dm=stop&amp;name_dm=Zugspitzstra%C3%9Fe&amp;itOptionsActive=1&amp;ptOptionsActive=1&amp;mergeDep=1&amp;useAllStops=1&amp;mode=direct&amp;anyMaxSizeHitList=10000&amp;useAllStops=1">API response</a>.</p>
<p>This is what it looks like, before formatting:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2019/08/image-134.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p>Now, take a look at the beautiful JSON response you get with <a target="_blank" href="https://github.com/callumlocke/json-formatter">JSON Formatter</a>:</p>
<p><img src="https://cdn-images-1.medium.com/max/1600/1*c_9u3i-WVKnhGZsd5UZ6Rw.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p><img src="https://www.freecodecamp.org/news/content/images/2019/08/image-135.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p>Here is a pro tip: Hold down CTRL (or CMD on Mac) while collapsing a tree, if you want to collapse all its siblings too.</p>
<p>It is an open-source project, so you can view its <a target="_blank" href="https://github.com/callumlocke/json-formatter">source code on GitHub</a>.</p>
<p>Thanks for reading.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How To Publish Your Chrome Extension ]]>
                </title>
                <description>
                    <![CDATA[ In my previous article I wrote, I talked about the building blocks of a Chrome extension. Now, I would like to focus on the other part of building a Chrome extension: what to do when you are done building it. The process in itself is not long nor har... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-publish-your-chrome-extension-dd8400a3d53/</link>
                <guid isPermaLink="false">66ba5023f8a814ef73b78bc0</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ coding ]]>
                    </category>
                
                    <category>
                        <![CDATA[ development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tech  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tomer ]]>
                </dc:creator>
                <pubDate>Fri, 30 Nov 2018 18:08:36 +0000</pubDate>
                <media:content url="https://cdn-media-1.freecodecamp.org/images/0*6SBGxnrpx8yGjo5S" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In my previous <a target="_blank" href="https://medium.freecodecamp.org/how-to-implement-a-chrome-extension-3802d63b5376">article</a> I wrote, I talked about the building blocks of a Chrome extension. Now, I would like to focus on the other part of building a Chrome extension: <strong>what to do when you are done building it</strong>. The process in itself is not long nor hard, but there are some things you should pay attention to.</p>
<p>Every Chrome extension is published on the <a target="_blank" href="https://chrome.google.com/webstore/category/extensions">Chrome Web Store</a>. Think of it as the equivalent to Google’s Play Store or Apple’s App Store, but only for Chrome extensions and themes.</p>
<h3 id="heading-steps">Steps</h3>
<p>If you haven’t created one already, you will need to create a Developer account. In it, you will have a Developer Dashboard.</p>
<blockquote>
<p>As I stated in the previous article, there is a $5 one time signup fee if you want to be able to publish Chrome extensions. This will give you the ability to publish up to 20 extensions</p>
</blockquote>
<p>Once you are the proud owner of a Developer account, the next step is to upload your Chrome extension to your account. To do this, create a ZIP file with all the files associated with your extension. <strong>The only file Google requires you to upload is the manifest.json file</strong>. But you will want to add the JavaScript files you have as well.</p>
<p>After that, the next step is to publish our extension. Login to your developer account and go to your Developer Dashboard.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/u6LZm6jafoX5k2lVN-j1m64kWSJuCm658oBK" alt="Image" width="188" height="269" loading="lazy"></p>
<p>There you will see an <strong>Add New Item</strong> button.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/Y5aNZVWVXR4nIZFiLm8yblYJVnv5dGVBEZ95" alt="Image" width="117" height="43" loading="lazy">
<em>Click Me</em></p>
<blockquote>
<p><strong><em>⚠️ Be aware that once you add an extension to your Developer Dashboard you cannot delete it. As long as it is not published, it will not count towards your extension limit.</em></strong></p>
</blockquote>
<p>This will direct you to a page where you can upload the ZIP file we created earlier:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/0kFfxuPAmDDtuVIZhzbxq29DXK9dUjvVmUbY" alt="Image" width="579" height="316" loading="lazy">
<em>Click choose file and press upload</em></p>
<p>Assuming everything went fine, you will be directed to the next page:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/w6sFu56M9LcBHekEYrid8oSWKC3ZN6XyfE8o" alt="Image" width="782" height="558" loading="lazy">
<em>Here you can provide a description of your extension</em></p>
<blockquote>
<p>If you are planning to make changes to your extension, you can use the Upload Updated Package button to re-upload your ZIP file.</p>
</blockquote>
<p>On this page, you can add an icon that will be shown on the toolbar:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/TypgvFz8T8rYSBC5--KHPZm-7pzJSJnJn9hQ" alt="Image" width="225" height="107" loading="lazy"></p>
<p>Add screenshots of your extension (these will be used when a user looks at your extension):</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/33NG7giJ5X3U2Jkzc-1tm1EGkbdJBEJl47Y1" alt="Image" width="798" height="171" loading="lazy"></p>
<p>And various other features like choosing a Category for the extension (I.E. Fun), choosing the regions where your extension will be available, the pricing of your extension, and other categories which I suggest you take a look at.</p>
<p>When you are done fine tuning the details of your extension, you will arrive at the end of the page and see the following buttons:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/-T9LN7NxeYyk8gqb4nuFe2p0J-P9zH9TvkKq" alt="Image" width="800" height="49" loading="lazy"></p>
<p>The two left buttons allow you to save the work you have done so far configuring your extension (or discarding it), and the two right ones are for when you are ready to publish. To see how everything you configured will look on the Chrome Web Store, press the <strong>Preview Changes</strong> button. When you are satisfied with what you have, click <strong>Publish Changes</strong>.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/i0xLibubdSwSbK0BDVOsPv3WTdfKFWSoGhrN" alt="Image" width="800" height="533" loading="lazy">
_Photo by [Unsplash](https://unsplash.com/@adspedia?utm_source=medium&amp;utm_medium=referral" rel="noopener" target="_blank" title=""&gt;Val Vesa on &lt;a href="https://unsplash.com?utm_source=medium&amp;utm_medium=referral" rel="noopener" target="<em>blank" title=")</em></p>
<p>Congratulations! You have just published your first Chrome Extension!</p>
<p>In your Developer Dashboard you will now see this:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/oBMLsvN4hlBDyUy0ZGwfVKOHFrJwi6Aa3Qso" alt="Image" width="800" height="92" loading="lazy"></p>
<p>Clicking the <em>Stats</em> link will give you analytics regarding your extension (how many impressions, installs and active users). I’m looking forward to seeing your published Chrome extensions.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to implement a Chrome Extension ]]>
                </title>
                <description>
                    <![CDATA[ We all like surfing the web. And we all like things to be at the touch of our fingertips. Why not create something that caters to these two absolute truths? In this article, I’ll explain the building blocks of a Chrome extension. Afterwards, you’ll j... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-implement-a-chrome-extension-3802d63b5376/</link>
                <guid isPermaLink="false">66ba5013a784afa7b3850fba</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ General Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tech  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tomer ]]>
                </dc:creator>
                <pubDate>Mon, 12 Nov 2018 16:29:07 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2019/07/1_IrKrGVmSj1DVh6kIsfqYyA.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>We all like surfing the web. And we all like things to be at the touch of our fingertips. Why not create something that caters to these two absolute truths?</p>
<p>In this article, I’ll explain the building blocks of a Chrome extension. Afterwards, you’ll just have to think of a good idea as an excuse to make one.</p>
<h3 id="heading-why-chrome">Why Chrome?</h3>
<p>Chrome is by far the most popular web browser. <a target="_blank" href="https://en.wikipedia.org/wiki/Usage_share_of_web_browsers">Some estimations are as high as <strong>59%</strong></a>. So, if you want to reach as many people as you can, developing a Chrome extension is the best way to do it.</p>
<p>⚠️ To be able to publish a Chrome extension, you need to have a developer account which entails a <a target="_blank" href="https://developer.chrome.com/webstore/publish">$5 one-time signup fee</a>.</p>
<p>Each Chrome extension should have these components: the <strong>manifest file</strong>, <strong>popup.html</strong> and <strong>popup.js</strong> and a <strong>background</strong> script. Lets take a look at them.</p>
<h3 id="heading-what-makes-up-a-chrome-extension">What makes up a Chrome extension?</h3>
<h4 id="heading-the-manifest-file">The Manifest File</h4>
<p>What is a manifest file? It is a text file in JSON (<a target="_blank" href="https://en.wikipedia.org/wiki/JSON">JavaScript Object Notation</a>) format that contains certain details about the extension you will be developing.</p>
<p>Google uses this file to acquire details about your extension when you will publish it. There are <strong>required</strong>, <strong>recommended</strong> and <strong>optional</strong> fields.</p>
<h4 id="heading-required">Required</h4>
<pre><code class="lang-js">{
  <span class="hljs-string">"manifest_version"</span>: <span class="hljs-number">2</span>,
  <span class="hljs-string">"name"</span>: <span class="hljs-string">"My Chrome Extension"</span>,
  <span class="hljs-string">"version"</span>: <span class="hljs-string">"1.0"</span>,
  <span class="hljs-string">"default_locale"</span>: <span class="hljs-string">"en"</span>
}
</code></pre>
<ul>
<li><code>manifest_version</code> - Version of the manifest file format. As of Chrome 18, version 1 is deprecated</li>
<li><code>name</code> - Can be up to 45 characters. Used to display your extension’s name in the following places: Install dialog, Extension management UI, Chrome Web Store</li>
<li><code>version</code> - Version of your Chrome Extension. Can be up to four digits separated by dots (e.g., 1.0.0.0)</li>
<li><code>default_locale</code> - This folder resides inside the <code>_locals</code> folder and it contains the default string literals. The <code>_locals</code> folder is used for internationalization (allowing your extension to support multiple languages). It is a required field if a <code>_locals</code> folder exists, otherwise, it should not be present</li>
</ul>
<p>If you want to support multiple languages, read more <a target="_blank" href="https://developer.chrome.com/extensions/i18n">here</a>.</p>
<h4 id="heading-recommended">Recommended</h4>
<pre><code class="lang-js">  <span class="hljs-string">"description"</span>: <span class="hljs-string">"A plain text description"</span>,
  <span class="hljs-string">"author"</span>: <span class="hljs-string">"Your Name Here"</span>,
  <span class="hljs-string">"short_name"</span>: <span class="hljs-string">"shortName"</span>,
  <span class="hljs-string">"icons"</span>: {
      <span class="hljs-string">"128"</span>:<span class="hljs-string">"icon128.png"</span>,
       <span class="hljs-string">"48"</span>:<span class="hljs-string">"icon48.png"</span>,
       <span class="hljs-string">"16"</span>:<span class="hljs-string">"icon16.png"</span>
    },
</code></pre>
<ul>
<li><code>description</code> - You can use up to 132 characters to describe the extension</li>
<li><code>short_name</code> - Limited to 12 characters, it is used in cases where there is not enough space to display the full name of the extension (App Launcher and New Tab Page)</li>
<li><code>icons</code> - The icons that represent the extension. <strong>Always include a 128X128 icon</strong> because it is used by the Chrome Web Store and during the installation of your extension</li>
</ul>
<p>Optional fields are case specific, so we won’t go into them in this article.</p>
<p>After covering the data needed for the manifest file, we can now move on to where we will actually be writing code for our extension, <strong>Popup and Background</strong>.</p>
<h4 id="heading-popup-and-background">Popup and Background</h4>
<p>The popup refers to the main page users see when using your extension. It consists of two files: <strong>Popup.html</strong> and a JavaScript file, <strong>Popup.js</strong>.</p>
<p>Popup.html is the layout file for how your extension will look like. Depending on what your extension will do, the markup of this file will change.</p>
<p>A background script is the only one that can interact with events that occur and use the Chrome API. To use background scripts you need to add the following to your manifest.json file:</p>
<pre><code class="lang-js">{
  <span class="hljs-string">"manifest_version"</span>: <span class="hljs-number">2</span>,
  <span class="hljs-string">"name"</span>: <span class="hljs-string">"My Chrome Extension"</span>,
  <span class="hljs-string">"version"</span>: <span class="hljs-string">"1.0"</span>,
  <span class="hljs-string">"background"</span>:{
        <span class="hljs-string">"scripts"</span>: [<span class="hljs-string">"yourScript.js"</span>],
        <span class="hljs-string">"persistent"</span>: <span class="hljs-literal">false</span>
    }
}
</code></pre>
<p>The key <code>scripts</code> has a value of an array of script names.</p>
<p><code>persistent</code> is a key with a boolean value which denotes the use of your extension with chrome.webRequest API to block or modify network requests. The Chrome.webRequest API does not work with non-persistent background pages.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/0*mFgdmSgmiXKQmhZ_" alt="Image" width="800" height="533" loading="lazy">
_“open signage on door” by [Unsplash](https://unsplash.com/@belart84?utm_source=medium&amp;utm_medium=referral" rel="noopener" target="_blank" title=""&gt;Artem Bali on &lt;a href="https://unsplash.com?utm_source=medium&amp;utm_medium=referral" rel="noopener" target="<em>blank" title=")</em></p>
<h3 id="heading-how-your-extension-will-open">How Your Extension Will Open</h3>
<p>Every Chrome extension adds a little icon to the toolbar at the top of your browser. Once the user clicks that icon, you can choose how you want your extension to open in the browser:</p>
<ol>
<li><p>It can override a new tab, so as not to disrupt the current user’s activity</p>
</li>
<li><p>You can open a small window in the user’s current tab, so as to keep the user in the same tab</p>
</li>
</ol>
<p>Each choice has it’s consequences and it is up to you to decide what is the best option for you.</p>
<p>Below is the code needed to pull off each of the options. They will both use the same popup.html file outlined below:</p>
<pre><code class="lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">html</span>&gt;</span>

    <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>

        <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>Chrome Extension Example<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>

    <span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>

        <span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span>Hello From Extension<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>

    <span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>


<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<h3 id="heading-putting-it-all-together">Putting It All Together</h3>
<h4 id="heading-override-new-tab">Override New Tab</h4>
<pre><code class="lang-js"><span class="hljs-comment">//In manifest.json</span>
{
    <span class="hljs-string">"name"</span>: <span class="hljs-string">"ChromeExampleNewTab"</span>,
    <span class="hljs-string">"version"</span>: <span class="hljs-string">"1.0"</span>,
    <span class="hljs-string">"manifest_version"</span>: <span class="hljs-number">2</span>,
    <span class="hljs-string">"chrome_url_overrides"</span>: {
        <span class="hljs-string">"newtab"</span>: <span class="hljs-string">"popup.html"</span>
    },
    <span class="hljs-string">"browser_action"</span>: {}, 
    <span class="hljs-string">"permissions"</span>:[        
        <span class="hljs-string">"tabs"</span>
    ],
    <span class="hljs-string">"background"</span>:{        
        <span class="hljs-string">"scripts"</span>: [<span class="hljs-string">"background.js"</span>],
        <span class="hljs-string">"persistent"</span>: <span class="hljs-literal">false</span>
    }
}

<span class="hljs-comment">//In background.js</span>
chrome.browserAction.onClicked.addListener(<span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">tab</span>) </span>{
    chrome.tabs.create({<span class="hljs-string">'url'</span>: chrome.extension.getURL(<span class="hljs-string">'popup.html'</span>)}, <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">tab</span>) </span>{
        <span class="hljs-comment">// Tab opened.</span>
    });
});
</code></pre>
<h4 id="heading-open-in-the-current-tab">Open In The Current Tab</h4>
<pre><code class="lang-js"><span class="hljs-comment">//In manifest.js</span>
{
    <span class="hljs-string">"name"</span>: <span class="hljs-string">"ChromeExample"</span>,
    <span class="hljs-string">"version"</span>: <span class="hljs-string">"1.0"</span>,
    <span class="hljs-string">"manifest_version"</span>: <span class="hljs-number">2</span>,
    <span class="hljs-string">"browser_action"</span>: {         
      <span class="hljs-string">"default_popup"</span>: <span class="hljs-string">"popup.html"</span>
    }
}
</code></pre>
<p>Notice how we have overridden the <code>browser_action</code> key in both examples.</p>
<p>We have to do this, since we don’t want the browser to open a new tab in the regular fashion.</p>
<p>Also, since if we want to open a new tab with our extension, we must add a permissions key to the manifest and specify the tabs value. This lets the browser know we need the user’s permission to overwrite the default behavior of opening a new tab.</p>
<p>There is a whole lot more to Chrome extensions (messaging, context menus and storage to name a few). I have hopefully given you some insight into Chrome extensions. Maybe just enough to intrigue you to make one of your own!</p>
<p>And while you are at it, check one I have made <a target="_blank" href="https://chrome.google.com/webstore/detail/gifted/jmhifaldhcbhfdgdbneekdaloednddco">here</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Medium Hates Him! See How He Improved the Stats Page With This One Simple Trick ]]>
                </title>
                <description>
                    <![CDATA[ By Tomas Trajan Yeah, the title, I know… but I had to try it at least once in my life ?? For many of us, Medium is the go-to platform for writing and publishing content online. It provides an extremely slick writing experience and, to be honest, I ca... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/medium-hates-him-see-how-he-improved-their-stats-page-with-this-one-simple-trick-1ce0898381a8/</link>
                <guid isPermaLink="false">66c35b3371e87702d4e5b70c</guid>
                
                    <category>
                        <![CDATA[ Blogger ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ coding ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ freeCodeCamp ]]>
                </dc:creator>
                <pubDate>Tue, 29 May 2018 10:43:05 +0000</pubDate>
                <media:content url="https://cdn-media-1.freecodecamp.org/images/1*y4m4pKrlHu_Scdbl26c0jw.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>By Tomas Trajan</p>
<p>Yeah, the title, I know… but I had to try it at least once in my life ??</p>
<p>For many of us, Medium is the go-to platform for writing and publishing content online. It provides an extremely slick writing experience and, to be honest, I can’t imagine using anything else anymore…</p>
<blockquote>
<p>I have been using Medium for years and I was always curious about the total reach of my articles</p>
</blockquote>
<p>Unfortunately, Medium stats can be described only as very basic or, to be frank, utterly lacking in the feature department. Even simple stuff like a summary row with a total count of the views and reads at the bottom of the table is missing.</p>
<p>The only solution is to add the numbers manually which is a boring, error-prone process. It gets progressively more tedious with the increasing number of articles, so you basically get punished for being a productive writer…</p>
<p>But hey, there is hope…</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/bGGbeoGFJwryJLAFpK1wjfJJzfl1glkn1vdY" alt="Image" width="1300" height="950" loading="lazy"></p>
<p>…at least for the people using <a target="_blank" href="https://chrome.google.com/webstore/detail/medium-enhanced-stats/jnomnfoenpdinfkpaaigokicgcfkomjo">Google Chrome</a> and Opera with this amazing <a target="_blank" href="https://addons.opera.com/en/extensions/details/install-chrome-extensions/">Opera addon</a> and then installing <a target="_blank" href="https://chrome.google.com/webstore/detail/medium-enhanced-stats/jnomnfoenpdinfkpaaigokicgcfkomjo">standard Chrome extension</a>.</p>
<blockquote>
<p>Introducing Medium Enhanced Stats</p>
</blockquote>
<h3 id="heading-what-is-in-it-for-you">What is in it for You? ????</h3>
<p>To put it briefly, there are four main features of Medium Enhanced Stats:</p>
<ol>
<li>Total reach indicator</li>
<li>Bar chart article markers</li>
<li>Stats table summary row and extra information</li>
<li>Support for users and publications</li>
<li>? An Easter Egg to be found, if you’re not afraid to roll up your sleeves and explore the source ???</li>
</ol>
<blockquote>
<p>Add <a target="_blank" href="https://chrome.google.com/webstore/detail/medium-enhanced-stats/jnomnfoenpdinfkpaaigokicgcfkomjo">Medium Enhanced Stats</a> to your Google Chrome now! Yes, it’s 100% FREE!</p>
</blockquote>
<p>Or to Opera with this amazing <a target="_blank" href="https://addons.opera.com/en/extensions/details/install-chrome-extensions/">Install Chrome Extensions addon</a>.</p>
<h3 id="heading-total-reach-indicator">Total reach indicator</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/8VrBm8c7TlM1awYV2nGbkiN9DgQdsw2HoDd4" alt="Image" width="800" height="728" loading="lazy"></p>
<p>Total reach is a sum of all the views of your articles and responses.</p>
<p>Instead of being a plain number, indicator contains a next milestone and a progress bar which shows how much you have already accomplished.</p>
<p>Milestone is calculated as a next 10x “round” number.</p>
<p>For example, people with a reach under 1K will achieve their 1K milestone pretty quick, but it will probably take more time to move from 1M to 10M…</p>
<blockquote>
<p>Then again, I would be very happy if you proved my assumption wrong ?</p>
<p>I would like to thank <a target="_blank" href="https://www.freecodecamp.org/news/medium-hates-him-see-how-he-improved-their-stats-page-with-this-one-simple-trick-1ce0898381a8/undefined">Johann Gyger</a> for his help with debugging the extension popup! ??????</p>
</blockquote>
<h3 id="heading-bar-chart-article-markers">Bar chart article markers</h3>
<p>Have you ever caught yourself wondering what was the cause of that pronounced views bump 3 months ago? Me too! Luckily the newest feature of Medium Enhanced Stats set out to solve just that…</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/OH2aIJYP2j7fUFt7qgjoMukDoRvS5dnXyHvp" alt="Image" width="2110" height="900" loading="lazy">
<em>Check out article markers in the bar chart and discover effect of their publishing on the overall performance…</em></p>
<p>Medium’s original bar chart is now enhanced with the article markers. It also works for responses and can handle displaying multiple articles per day.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/w38exCH-FhiHVHANDmnaSq72Yo3QNn5mbBbx" alt="Image" width="800" height="237" loading="lazy">
<em>Markers can handle also multiple articles per day and the marker size reflects amount of articles… Yes, bigger IS better ?</em></p>
<h3 id="heading-summary-row-and-extra-information">Summary row and extra information</h3>
<p>This was the initial feature of the extension, and was basically the way it all started out: having a simple summary row which displays the sum of the values per column.</p>
<blockquote>
<p>You know, Excel sum ∑ stuff…</p>
</blockquote>
<p>As it turned out, the retrieved data also contains the amount of claps per article. This is nowadays a much more useful metric, since Medium switched to displaying claps also in the UI of the articles themselves.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/BF-YpLEjIlfOV0TUfZW7DJ9s4ZKYh-piWOIP" alt="Image" width="800" height="247" loading="lazy">
<em>Summary row is exactly what it sound it is ? Besides that, there is also an additional claps column for every article…</em></p>
<h3 id="heading-support-for-users-and-publications">Support for users and publications</h3>
<p>In the beginning, Medium Enhanced Stats could only display stats for a single currently logged in author. Feedback from the first users came pretty fast: they were asking for the ability to do the same for their publications.</p>
<blockquote>
<p>Just installed a new chrome extension <a target="_blank" href="https://t.co/azWSmxsnFY">http://goo.gl/XBvNFu</a> by <a target="_blank" href="https://twitter.com/tomastrajan">@tomastrajan</a>. Very convenient. It says I’ve reached more than 1 million people! And it now encourages me to aim for 10 millions :). Great work, <a target="_blank" href="https://twitter.com/tomastrajan">@tomastrajan</a>! <strong>Can it show stats for a publication?</strong> — <a target="_blank" href="https://www.freecodecamp.org/news/medium-hates-him-see-how-he-improved-their-stats-page-with-this-one-simple-trick-1ce0898381a8/">Max NgWizard K</a></p>
</blockquote>
<p><strong>Ask and you shall be given!</strong></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/eTqTvwzoxqvYjX2gS7pWx1rAJ0bXbMxuKphE" alt="Image" width="1330" height="900" loading="lazy">
<em>Click on the user avatar and select from available users and publications</em></p>
<h4 id="heading-technical-background-for-my-fellow-developers">Technical background for my fellow developers</h4>
<p>Medium Enhanced Stats is a Chrome Extension. Chrome Extensions are great for enriching existing websites with custom functionality, because we have the possibility to inject custom scripts in a safe way.</p>
<p>Custom script can access and leverage all the visible and invisible data available on the original page. More so, because they now belong to the original site, they can also make requests on its behalf. And let me tell you, it is much easier to calculate totals from JSON data than to scrape an HTML table.</p>
<p>I am planning to write another post which will get much more into details of how to implement Chrome extensions so <a target="_blank" href="https://twitter.com/tomastrajan">stay tuned</a>…</p>
<blockquote>
<p><strong>FUN FACT —</strong> Medium Enhanced Stats retrieves and show total stats, right? Well, it’s almost total: you might need to worry if you’ve published more than <strong>100k articles,</strong> which is currently the limit of the paging request ?</p>
</blockquote>
<p>Before I forget, Medium Enhanced Stats is also fully open sourced on GitHub, so feel free to <a target="_blank" href="https://github.com/tomastrajan/medium-enhanced-stats">check it out</a>. There is virtually no documentation available yet, but I will definitely add more in the future to enable community-driven efforts!</p>
<h3 id="heading-future">Future</h3>
<p>Since its inception, the extension has already gone through a couple of major iterations and improvements.</p>
<p>Currently, there is a plan to try to visualise articles which are the major contributors to views each day. It can get rather tricky in situations with lots of articles with small individual contributions. It will be important to strike a good balance to make it useful instead of distracting.</p>
<p>Another opportunity worth exploring is adding a way to download and share a stylized total reach indicator with author’s name, Medium username, and other social media handles. This could be useful because authors would gain an easy way to communicate their contribution as a proxy of their trustworthiness for new members of their communities.</p>
<h3 id="heading-this-is-the-end">This is the end!</h3>
<p>I hope you will try <a target="_blank" href="https://chrome.google.com/webstore/detail/medium-enhanced-stats/jnomnfoenpdinfkpaaigokicgcfkomjo"><strong>Medium Enhanced Stats</strong></a> out and let me know about your experience and possible enhancement ideas!</p>
<p>Please, help spread this article to a wider audience with your ? ? ? and fol<a target="_blank" href="https://twitter.com/tomastrajan">low me on ?️ Twitter to</a> get notified about my newest blog posts ?</p>
<blockquote>
<p><em>And never forget, the future is bright.</em></p>
</blockquote>
<p><img src="https://cdn-media-1.freecodecamp.org/images/TkU6BdKH0HFXacxUWsDtvHTCtsPv2X54PVab" alt="Image" width="800" height="585" loading="lazy">
_Obviously the bright future (? by S[asha • Stories)](https://unsplash.com/@sanfrancisco" rel="noopener" target="<em>blank" title=")</em></p>
<p>If you made it this far, feel free to check out some of my other articles about frontend software development…</p>
<p><a target="_blank" href="https://medium.com/@tomastrajan/how-to-stay-up-to-date-with-releases-of-popular-frontend-frameworks-with-twitter-bot-release-butler-86af7b734706"><strong>How To Stay Up To Date With Releases Of Popular Frameworks</strong></a><br><a target="_blank" href="https://medium.com/@tomastrajan/how-to-stay-up-to-date-with-releases-of-popular-frontend-frameworks-with-twitter-bot-release-butler-86af7b734706">_Introducing Release Butler — A Twitter Bot That Helps You To Stay Up To Date With Releases Of Popular Frontend…_medium.com</a><a target="_blank" href="https://medium.com/@tomastrajan/how-to-build-responsive-layouts-with-bootstrap-4-and-angular-6-cfbb108d797b"><strong>How To Build Responsive Layouts With Bootstrap 4 and Angular 6 ?</strong></a><br><a target="_blank" href="https://medium.com/@tomastrajan/how-to-build-responsive-layouts-with-bootstrap-4-and-angular-6-cfbb108d797b">E_very web app is assumed to be responsive, period.m_edium.com</a> <a target="_blank" href="https://medium.com/@tomastrajan/how-to-speed-up-continuous-integration-build-with-new-npm-ci-and-package-lock-json-7647f91751a"><strong>How To Speed Up Continuous Integration Build With New NPM CI And package-lock.json</strong></a><br><a target="_blank" href="https://medium.com/@tomastrajan/how-to-speed-up-continuous-integration-build-with-new-npm-ci-and-package-lock-json-7647f91751a">_While very controversial, the new npm release 5.7.0 brings some amazing features which will have noticeable positive…_medium.com</a></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/oc3DlDTyqRJBctnhI4GhGrbT28932HJSPMWP" alt="Image" width="800" height="320" loading="lazy">
<em>Don’t forget ! ?</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How I Reverse Engineered A Chrome Extension To Write My Own Flask App ]]>
                </title>
                <description>
                    <![CDATA[ By Tushar Agrawal Basically, if I have no intention of using a service then I won’t bother reverse-engineering it. — Jon Lech Johansen As evident from my bio, I am crazy about music and pretty much anything related to it. And I believe that music v... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-i-reverse-engineered-a-chrome-extension-to-write-my-own-flask-app-d77f36421106/</link>
                <guid isPermaLink="false">66c34e0c0fa3812cdd5ea9f5</guid>
                
                    <category>
                        <![CDATA[ Google Chrome ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ reverse engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ freeCodeCamp ]]>
                </dc:creator>
                <pubDate>Fri, 02 Feb 2018 17:05:34 +0000</pubDate>
                <media:content url="https://cdn-media-1.freecodecamp.org/images/1*uOlxCoXq1faNmKA-3ie2Bg.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>By Tushar Agrawal</p>
<blockquote>
<p>Basically, if I have no intention of using a service then I won’t bother reverse-engineering it. — <em>Jon Lech Johansen</em></p>
</blockquote>
<p>As evident from my bio, I am crazy about music and pretty much anything related to it. And I believe that music videos, if well-directed, are possibly the best way to feel the inherent soul of music. </p>
<p>So, it all began with me watching the music video of a song “<strong>Heavydirtysoul</strong>” by <strong>Twenty One Pilots</strong>. The music video was so dope I didn’t even care for the lyrics. It was only after I listened to it a few times, I realized that I didn’t get much of the lyrics except the chorus part. </p>
<p>This is something that is an actual problem for many ESL (English as a Second Language) speakers. You can’t enjoy a song to its fullest if you don’t get the lyrics. </p>
<p>It was then that I thought of something: what if I could play the lyrics of a song alongside the music videos (much like subtitles)? It would be awesome if I could create subtitle files for my music videos and then play it on my video player!</p>
<h2 id="heading-initial-approach-and-finding-musixmatch">Initial Approach and finding Musixmatch</h2>
<p>I then began a comprehensive search for sites or APIs that could provide me the lyrics for a song. And as expected, I found a dozen sites that provided the lyrics. Cool… isn’t it? </p>
<p>Nah. Because, what I really needed was timed lyrics, much like a subtitle for a movie. I wanted the lyrics text to <strong>sync</strong> with the current video frame on the screen. After much searching, I was unable to find any such service.</p>
<p>It was only after a week someone told me to use <strong>Musixmatch</strong>, a chrome extension that embedded lyrics on YouTube videos. So, yeah, there was someone out there who was already doing what I had thought about. It sounded like most of the other well thought so-called new ideas I had...and I was just a step away from fetching SubRip “srt” subtitle files for my favorite music videos.</p>
<h3 id="heading-and-the-hacking-started">And the hacking started…</h3>
<p>I already had a bit of experience working with the chrome developer tools (thanks to Node.js and front end designing). So I put on my hacker glasses and fired up Chrome Dev tools. I switched to the network tab and began to look for any text file that could contain the lyrics.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/1*O89WdLDnoVHaOvXK5T-A5w.jpeg" alt="Image" width="800" height="342" loading="lazy">
<em>Snapshot of developer tools with YouTtube video playing</em></p>
<p>But I was analyzing requests on a page that was playing YouTube videos, so I had a plenty of requests. And since the extension was fetching lyrics, the request must have something to do with the Musixmatch domain. </p>
<p>So I filtered using the keyword ‘musix’ and looked patiently for my file and I finally found it. Lyrics along with the time stamp. I noted the URL of that request and frankly, it all seemed like gibberish to me. Anyways, I copied the URL string as such and then pasted it into the URL bar, and voilà, I got the lyrics. </p>
<p>So, the only thing left was to find out how the URL is being framed and what were the parameters..</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/1*JlKw3JsfgOuUkgG3SS0rRw.jpeg" alt="Image" width="800" height="107" loading="lazy">
<em>Request URL</em></p>
<h3 id="heading-parameters-and-what">Parameters and what?</h3>
<p>After all the analyzing and filtering, I finally ended up with this. A long URL with a bunch of unknown parameters.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/1*1kfqTsYqS8MjhQY5Dyqq0A.jpeg" alt="Image" width="755" height="239" loading="lazy">
<em>Parameters for the URL</em></p>
<p>I needed to dig deeper to actually understand the importance of each parameter. At a glance, it was clear that the only parameters that actually mattered were <code>res</code> and <code>v</code>. Others were just for house-keeping stuff. Then I began to explore the options and ended up wasting an hour just to find that the parameter <code>v</code> is nothing but the YouTube Video Id.</p>
<p>For example, the Video Id or <code>v</code> for a YouTube video with a URL https://www.youtube.com/watch?v=ZQeq_T_2VE8 is <code>ZQeq_T_2VE8</code>. Now that I had unveiled the mystery of <code>v</code>, I thought it would take me hardly another hour to find about <code>res</code>, but boy was I wrong.</p>
<h3 id="heading-the-curious-case-of-the-parameter-res">The curious case of the parameter ‘res’</h3>
<p>An hour of deep analysis and research gave me nothing. A little later, I realized that the URL worked even when I changed few alphabets. I kept up digging and by the end of the 3 hours, I figured out that the alphabets in the string didn’t mean anything. They were just put randomly.</p>
<pre><code>A typical value <span class="hljs-keyword">of</span> res : <span class="hljs-number">90</span>rt120b114xz70xv82w85vv90a94hn90vb102av86
</code></pre><p>So I was done with the alphabets but the numeric values were still alien to me. The next thing I could think of was applying a bit of reverse-engineering to analyze the numbers. </p>
<p>I began with removing all the alphabets as they didn’t mean anything and the first thing I noticed that the number of those values were fixed, the number being 11. I tried it with many other videos, but the number remained constant. </p>
<p>Suddenly, it struck me, Video Id, the <code>v</code>, we discussed earlier also had 11 characters. However, each character in <code>v</code> could be an alphabet or a digit or even a ‘-’ or ‘_’, unlike <code>res</code> which had only numbers. </p>
<p>So, I tried the most obvious mapping that can map a character to its numeric value, ASCII, and voilà that was it. The characters were ASCII encoded and alphabets were randomly put in between the numbers to make the whole string look more random, I guess.</p>
<p>At this point, I was delighted. After all, I had learned about all the parameters and was only a step away from writing my own handy script to download the lyrics file in “srt” format. Just to be sure, I checked with different videos and there seemed to be no issue whatsoever. I also shared the URL with one of my friends (yeah, a music lover).</p>
<p>I got a quick reply and it said “What is it? There’s nothing”. I crosschecked the URL and it was working fine on my browser.</p>
<h3 id="heading-who-was-the-culprit-p">Who was the culprit ? :P</h3>
<blockquote>
<p>I don’t get sent anything strange like underwear. I get sent cookies. :P — Jennifer Aniston</p>
</blockquote>
<p><img src="https://cdn-media-1.freecodecamp.org/images/1*zfKtWngPstlY9a7Iw64p3g.jpeg" alt="Image" width="800" height="302" loading="lazy">
<em>Cookie field in the Request Headers</em></p>
<p>I fired up the developer tools again and then copied the link for a new song. It again worked and then I switched to an incognito tab and pasted that same URL. It didn’t work. </p>
<p>My experience of CTF (Capture The Flag) contests immediately told me that it had something to do with the cookies. That’s the most likely case if a URL is working in a browser window and not the other. </p>
<p>I switched to the developer console and saw that the cookie was indeed being sent by the browser. To be sure, I analyzed the request many times and it finally occurred to me that the cookie being sent was the same the Musixmatch server is sending in the response. Also, each cookie is valid for only a certain time period. </p>
<p>So, I wrote a Python script using urllib that first gets the cookie from a normal HTTP response since the cookie works across the domain. Then the cookie along with other parameters was framed as an HTTP request and we got the lyrics... Finally!!</p>
<h3 id="heading-preparing-the-parameters-for-a-successful-request">Preparing the parameters for a successful request</h3>
<p>Here is the Python code for all the steps discussed above. The code first generates the parameters followed by a request to get the cookies. URL is then prepared using the parameters. Next, the cookie is defined in the header request along with other header fields like ‘Host’ and ‘User-agent’ to give it more of an authentic request look.</p>
<h3 id="heading-parsing-the-raw-timed-lyrics-into-srt-format">Parsing the raw timed lyrics into srt format</h3>
<p>Now, the next major thing or the only task left was to convert the raw timed lyrics data into a proper srt (SubRip Text) format. Here is what the MusixMatch lyrics format looked like.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/1*FcdBLOPuQQddd7uM6vYTrQ.jpeg" alt="Image" width="732" height="206" loading="lazy">
<em>HTTP Response for the lyrics</em></p>
<p>Below is a proper format for a srt file.These files contain formatted lines of plain text in groups separated by a blank line. Subtitles are numbered sequentially, starting at 1 as depicted in the figure below.</p>
<pre><code><span class="hljs-number">100</span>:<span class="hljs-number">00</span>:<span class="hljs-number">00</span>,<span class="hljs-number">350</span> --&gt; <span class="hljs-number">00</span>:<span class="hljs-number">00</span>:<span class="hljs-number">03</span>,<span class="hljs-number">45071</span> buildings explodedor caught fire.
</code></pre><pre><code><span class="hljs-number">200</span>:<span class="hljs-number">00</span>:<span class="hljs-number">03</span>,<span class="hljs-number">490</span> --&gt; <span class="hljs-number">00</span>:<span class="hljs-number">00</span>:<span class="hljs-number">05</span>,<span class="hljs-number">020</span>Elliot, tell me what it isthat you think he did.
</code></pre><pre><code><span class="hljs-number">300</span>:<span class="hljs-number">00</span>:<span class="hljs-number">05</span>,<span class="hljs-number">060</span> --&gt; <span class="hljs-number">00</span>:<span class="hljs-number">00</span>:<span class="hljs-number">06</span>,<span class="hljs-number">930</span>Sorry.I don<span class="hljs-string">'t know if I can say.</span>
</code></pre><p>This sounded like a whole lot of work was required as the data was yet to be properly formatted. But, if you have the required data and a knowledge of Python, all it takes is a simple script to handle the data and that’s exactly what I did. The HTML tags annoyed me a bit during HTML parsing but guess what, there is an awesome library just for HTML parsing which made the whole process very easy. No points for guessing the library’s name, HTMLParser :-).</p>
<h2 id="heading-final-words">Final words</h2>
<p>So, I put together this script along with some modifications and with a simple front end on a flask server, I had my own lyrics fetching interface, possibly the only one of its kind in the whole world !!</p>
<p>By the way, if you are into music, have a look at Musixmatch. It is really awesome. This exercise was just for educational purposes and wasn’t used in any way to violate Musixmatch’s copyright.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
