<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tiger Liu</title>
    <description>The latest articles on DEV Community by Tiger Liu (@tigerhuliu).</description>
    <link>https://dev.to/tigerhuliu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4109280%2F542182dc-d493-49e2-bdcd-e74599090d05.png</url>
      <title>DEV Community: Tiger Liu</title>
      <link>https://dev.to/tigerhuliu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tigerhuliu"/>
    <language>en</language>
    <item>
      <title>How to ship a Chrome extension: the code is the easy half published: false</title>
      <dc:creator>Tiger Liu</dc:creator>
      <pubDate>Sat, 05 Sep 2026 14:49:17 +0000</pubDate>
      <link>https://dev.to/tigerhuliu/how-to-ship-a-chrome-extension-the-code-is-the-easy-halfpublished-false-mg7</link>
      <guid>https://dev.to/tigerhuliu/how-to-ship-a-chrome-extension-the-code-is-the-easy-halfpublished-false-mg7</guid>
      <description>&lt;p&gt;I have 18 extensions live on the Chrome Web Store. Writing them was never the hard part.&lt;/p&gt;

&lt;p&gt;Every tutorial I read stopped at the same place: load unpacked, it works, ship it. All the failures that actually cost me time happen after that line. Three of my submissions came back rejected, each with a colour-coded reference ID that the docs mention but never really explain.&lt;/p&gt;

&lt;p&gt;So here's the walkthrough in the proportion I wish someone had given me: the build, quickly, and then the review, slowly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1 — the extension
&lt;/h2&gt;

&lt;p&gt;I use Plasmo. React and TypeScript, and the thing I actually stayed for: the manifest lives in &lt;code&gt;package.json&lt;/code&gt;, so there's one file to edit instead of two that drift apart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"manifest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"__MSG_extName__"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"__MSG_extDesc__"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"default_locale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"minimum_chrome_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"116"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bookmarks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sidePanel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"contextMenus"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tabs"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.example.com/*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"optional_host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"http://*/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://*/*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"side_panel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tabs/sidepanel.html"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"commands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"toggle-spotlight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"suggested_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ctrl+Shift+K"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mac"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Command+Shift+K"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"__MSG_cmdToggleSpotlight__"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The directory layout is convention, not configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;popup.tsx        the toolbar popup
tabs/            full pages inside the extension
contents/        content scripts
background/      the MV3 service worker
locales/         _locales messages, one folder per language
build/
  chrome-mv3-dev    &amp;lt;- load unpacked from here
  chrome-mv3-prod   &amp;lt;- the only thing you ever upload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's genuinely most of it. An afternoon gets you something that loads and does a thing.&lt;/p&gt;

&lt;p&gt;Three notes that are not obvious, and that I got wrong first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put every piece of core logic in a function that doesn't touch the DOM.&lt;/strong&gt; Payload builders, parsers, whatever your product actually does. Then you can test it in plain Node with no browser at all, and what's left for the slow browser tests is only the stuff that genuinely needs &lt;code&gt;chrome.*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extension e2e tests need real Chromium, and "headless" isn't enough.&lt;/strong&gt; Playwright's default headless mode runs &lt;code&gt;chrome-headless-shell&lt;/code&gt;, which cannot load extensions at all. You need the full browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launchPersistentContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chromium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`--disable-extensions-except=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;EXT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`--load-extension=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;EXT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serviceWorkers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;sw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;serviceworker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;// your extension id, at last&lt;/span&gt;
&lt;span class="c1"&gt;// now open chrome-extension://&amp;lt;id&amp;gt;/popup.html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most valuable assertion in that suite isn't a feature check. It's collecting every &lt;code&gt;console&lt;/code&gt; error and &lt;code&gt;pageerror&lt;/code&gt; across all pages and asserting the total is zero at the end. That one catches white screens, which are otherwise the bug you find out about from a one-star review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your dev host permissions must not reach the store.&lt;/strong&gt; The service worker needs &lt;code&gt;http://localhost/*&lt;/code&gt; to talk to your local API, so it has to be in &lt;code&gt;package.json&lt;/code&gt; or you can't develop. But shipping it means a reviewer asks why a published extension wants access to the user's localhost, and you have no good answer — plus it adds a permission warning on the install screen for every user.&lt;/p&gt;

&lt;p&gt;Don't delete it. Strip it from the &lt;em&gt;built&lt;/em&gt; manifest, after the build and before packaging, with a script that exits non-zero if it finds any left. The dev command never runs that script, so local development is untouched.&lt;/p&gt;

&lt;p&gt;That's the shape of every fix in the rest of this post, by the way: a build step that fails loudly instead of a rule you have to remember.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2 — the review
&lt;/h2&gt;

&lt;p&gt;Here's the part nobody writes tutorials about.&lt;/p&gt;

&lt;p&gt;A Chrome Web Store rejection arrives as an email naming a policy and a two-word reference ID. It tells you what you violated. It does not tell you how to fix it, and if your fix is wrong you find out several days later. I got three distinct ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blue Argon — remotely hosted code
&lt;/h3&gt;

&lt;p&gt;MV3 bans remotely hosted code. What took me a while to understand is that the check is &lt;strong&gt;static&lt;/strong&gt;. A scanner reads your uploaded bundle and looks for two shapes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;remote script references — &lt;code&gt;&amp;lt;script src="https://…"&amp;gt;&lt;/code&gt;, &lt;code&gt;import("https://…")&lt;/code&gt;, any URL ending in &lt;code&gt;.js&lt;/code&gt; / &lt;code&gt;.mjs&lt;/code&gt; / &lt;code&gt;.wasm&lt;/code&gt;, and the usual CDN hosts&lt;/li&gt;
&lt;li&gt;dynamic execution — &lt;code&gt;eval(&lt;/code&gt;, and the &lt;code&gt;Function&lt;/code&gt; constructor in both spellings&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Whether the code can ever run is irrelevant. It's in the bundle, so it counts.&lt;/p&gt;

&lt;p&gt;And the culprit is almost never your own code. It's a dependency. The one that actually got me was a PDF library whose preview output embeds a viewer script from a CDN as a string. A real remote script, sitting in the bundle, in a code path my product never calls. The same library also has the classic &lt;code&gt;Function("return this")()&lt;/code&gt; globalThis fallback, and a zip library nearby had &lt;code&gt;Function("" + fn)&lt;/code&gt; as a &lt;code&gt;setImmediate&lt;/code&gt; shim. Neither branch can execute on Chrome 102+. Both get flagged anyway.&lt;/p&gt;

&lt;p&gt;So I scan the build output — not the source — and gate on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VIOLATIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Function constructor / eval&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;re&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;^|&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="sr"&gt;$&lt;/span&gt;&lt;span class="se"&gt;])(&lt;/span&gt;&lt;span class="sr"&gt;new&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+Function|Function|eval&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remote script URL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;re&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/[^\s&lt;/span&gt;&lt;span class="sr"&gt;"'`)&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.(?:&lt;/span&gt;&lt;span class="sr"&gt;js|mjs|wasm&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;// benign, do not touch: XML namespaces and licence comments&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BENIGN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;/https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;www&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;w3&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;org&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;purl&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;org&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wired in as &lt;code&gt;"package": "build &amp;amp;&amp;amp; node scripts/check-rhc.mjs &amp;amp;&amp;amp; package"&lt;/code&gt;, so a bad bundle can't reach the zip step.&lt;/p&gt;

&lt;p&gt;Two things I'd underline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write that regex in JavaScript, not shell.&lt;/strong&gt; I tried &lt;code&gt;grep -E&lt;/code&gt; first. POSIX ERE doesn't understand &lt;code&gt;\w&lt;/code&gt; inside a bracket expression — it reads it as a literal backslash and a literal &lt;code&gt;w&lt;/code&gt; — so &lt;code&gt;[^.\w]Function\(&lt;/code&gt; happily matches the tail of &lt;code&gt;headerFunction(&lt;/code&gt; and you get a pile of false positives. Node's regex engine has real &lt;code&gt;\w&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't strip the benign URLs.&lt;/strong&gt; &lt;code&gt;http://www.w3.org/2000/svg&lt;/code&gt; is an XML namespace. Blank it out and your SVGs stop rendering, which is a much worse day than a rejection.&lt;/p&gt;

&lt;p&gt;And: only ever upload the production build. Dev builds contain a &lt;code&gt;localhost&lt;/code&gt; HMR script loader, which is, definitionally, remotely hosted code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Yellow Argon — keyword spam
&lt;/h3&gt;

&lt;p&gt;This one rejected me twice, and the two rejections together are what taught me the actual rule.&lt;/p&gt;

&lt;p&gt;The extension integrates with six third-party products. Naturally I listed them. The first rejection quoted six separate passages back at me: the six names as a comma list appeared three times across the name, the short description and the detailed description; a list of nine file formats appeared three times; and thirteen UI language names were spelled out once.&lt;/p&gt;

&lt;p&gt;Fair enough. I rewrote it so each name appeared exactly once, in prose, in a real sentence.&lt;/p&gt;

&lt;p&gt;Rejected again. This time they quoted exactly one passage — that sentence. Six third-party product names in a single sentence is keyword spam whatever the grammar around it.&lt;/p&gt;

&lt;p&gt;So the rule isn't a frequency budget. It's this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Third-party proper nouns in your store copy: zero.&lt;/strong&gt; Not "fewer", not "as prose". Zero. Users can still see what you support — the store page has a site-access panel generated from your &lt;code&gt;host_permissions&lt;/code&gt;, which isn't your metadata and isn't your problem, and your screenshots show real product UI with real labels on it. Neither of those got flagged in either round.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own feature words are fine.&lt;/strong&gt; One format name appears seven times in my current listing, every time inside an actual explanation of what the feature does. Never flagged once. What's policed is third-party names, not repetition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Irrelevant keywords" means what it says.&lt;/strong&gt; A list of the languages your UI is translated into is the textbook case. Write "13 interface languages" and move on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your disclaimer counts.&lt;/strong&gt; "Not affiliated with A, B, C, D, E or F" was, in my first draft, the second and third occurrence of the whole brand list. It rewrites cleanly to "not affiliated with, endorsed by or sponsored by the companies that operate the sites it supports" — same legal meaning, zero names. Just don't write "the sites listed above", because after this edit there is no list above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The name field is not a keyword slot.&lt;/strong&gt; &lt;code&gt;Thing — Format A &amp;amp; Format B&lt;/code&gt; duplicates your short description and is judged on the same surface.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Red Nickel — promotional words
&lt;/h3&gt;

&lt;p&gt;The store forbids promotional and superlative claims in listing copy &lt;em&gt;and&lt;/em&gt; in your images: free, best, #1, new, recommended, and friends.&lt;/p&gt;

&lt;p&gt;I knew that for the text. I did not know the images get OCR'd. A promo tile of mine had &lt;code&gt;Free · Private · 100% Local&lt;/code&gt; set in type across it, and the rejection named the exact asset and the exact word.&lt;/p&gt;

&lt;p&gt;Two consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After you fix the wording you have to &lt;strong&gt;re-render the PNG&lt;/strong&gt;. You upload an image, not the config file that generated it. Ask me how I know.&lt;/li&gt;
&lt;li&gt;Substrings are a real risk. "A freely selected region" contains "free". I now avoid the sequence entirely and describe the behaviour instead — "a region you draw".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Factual words are safe and, honestly, better copy: &lt;code&gt;Private&lt;/code&gt;, &lt;code&gt;100% Local&lt;/code&gt;, &lt;code&gt;Offline&lt;/code&gt;, &lt;code&gt;No account&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The listing form itself
&lt;/h2&gt;

&lt;p&gt;A few shapes of the developer dashboard that cost me a resubmission each:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every entry in &lt;code&gt;permissions&lt;/code&gt; gets its own justification box, 1000 characters each. All of &lt;code&gt;host_permissions&lt;/code&gt; shares one single box&lt;/strong&gt; — no matter how many domains you declared. So you cannot write it domain by domain. I had five well-organised paragraphs, about 2000 characters, and nowhere to paste them. It has to be one paragraph merged by &lt;em&gt;purpose&lt;/em&gt;: what you read from the page, at what rate you fetch, that you never write; one clause for your own backend; and your &lt;code&gt;optional_host_permissions&lt;/code&gt; folded into the same paragraph rather than getting its own. Cutting to fit is easiest if you delete the topic sentence and open on the first domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Justify the permissions in the built manifest, not the ones in &lt;code&gt;package.json&lt;/code&gt;.&lt;/strong&gt; The bundler can add permissions you never wrote. A content script running in the &lt;code&gt;MAIN&lt;/code&gt; world can't be declared in the manifest at all under MV3, so it gets registered at runtime by the service worker via &lt;code&gt;chrome.scripting.registerContentScripts()&lt;/code&gt; — which means &lt;code&gt;scripting&lt;/code&gt; appears in your shipped manifest out of nowhere. The reviewer asks about it. Your generated paperwork doesn't mention it, because it read the wrong file.&lt;/p&gt;

&lt;p&gt;While you're there: justify &lt;code&gt;scripting&lt;/code&gt; with what it actually does. The stock answer everyone copies is "we inject scripts on demand with &lt;code&gt;executeScript&lt;/code&gt;". If that's not true for you, don't say it. Mine registers once, with matches identical to &lt;code&gt;host_permissions&lt;/code&gt;, from files inside the package, and never calls &lt;code&gt;executeScript&lt;/code&gt;. That's both truthful and less alarming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The manifest &lt;code&gt;description&lt;/code&gt; has a 132-character limit &lt;em&gt;per locale&lt;/em&gt;.&lt;/strong&gt; When it goes through &lt;code&gt;__MSG_extDesc__&lt;/code&gt;, upload validates every &lt;code&gt;_locales/&amp;lt;lang&amp;gt;/messages.json&lt;/code&gt;, not just your default one, and stops at the first one over. Translations run 20–60% longer than English, so I cap the English at 120 and check the rest in a script. (This is not the same field as the store listing's short description. Different limits, different places.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know which edits need a new package.&lt;/strong&gt; Name and short description ship inside the zip, so changing them means a version bump and a re-upload. The detailed description exists only in the dashboard — edit and resubmit, no new build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually tell you
&lt;/h2&gt;

&lt;p&gt;Write the permission justifications before you write the code.&lt;/p&gt;

&lt;p&gt;It sounds like a process tip. It isn't. Every permission is a question you will have to answer in writing, in a 1000-character box, to a stranger who is looking for a reason to say no. Answering that question first changes what you build, and in my experience it usually makes the extension smaller.&lt;/p&gt;

&lt;p&gt;The rest of it generalises to one line: every rule in this post is statically checkable, so none of them should live in your head. Remote code, leftover localhost permissions, over-length descriptions, banned words in copy — that's four scripts that fail your build. I still get rejected sometimes. I don't get rejected twice for the same thing anymore.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance for the English drafting. The extensions, the rejections and the scripts are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>extensions</category>
      <category>typescript</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
