<?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: Chris Morris</title>
    <description>The latest articles on DEV Community by Chris Morris (@chris_morris).</description>
    <link>https://dev.to/chris_morris</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%2F4004554%2F7e888014-decb-4ea7-a10b-eeb7edff7ab6.jpg</url>
      <title>DEV Community: Chris Morris</title>
      <link>https://dev.to/chris_morris</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chris_morris"/>
    <language>en</language>
    <item>
      <title>Resolving color contrast over CSS gradients</title>
      <dc:creator>Chris Morris</dc:creator>
      <pubDate>Mon, 20 Jul 2026 17:23:34 +0000</pubDate>
      <link>https://dev.to/chris_morris/resolving-color-contrast-over-css-gradients-3ke9</link>
      <guid>https://dev.to/chris_morris/resolving-color-contrast-over-css-gradients-3ke9</guid>
      <description>&lt;p&gt;Run an automated accessibility check on a modern landing page and the headline often comes back as "needs review" rather than pass or fail. The usual reason is a gradient. Here is why that happens, and the small piece of maths that resolves most of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why gradients become a blind spot
&lt;/h2&gt;

&lt;p&gt;The contrast check in &lt;a href="https://github.com/dequelabs/axe-core" rel="noopener noreferrer"&gt;axe-core&lt;/a&gt; compares the colour of text against the colour behind it. To do that reliably it reads the computed &lt;code&gt;background-color&lt;/code&gt; of the element and its ancestors, a single solid value it can reason about.&lt;/p&gt;

&lt;p&gt;A CSS gradient is not a solid colour. It is set through &lt;code&gt;background-image: linear-gradient(...)&lt;/code&gt;, so when text sits directly on one, axe has no single background value to measure against. Rather than guess, it does the responsible thing and returns the result as &lt;strong&gt;incomplete&lt;/strong&gt;, the "needs review" state. That is correct, but it has an awkward side effect: the text it punts on is frequently the &lt;em&gt;most prominent&lt;/em&gt; text on the page, the hero headline sitting on a colourful gradient banner.&lt;/p&gt;

&lt;h2&gt;
  
  
  The insight: the worst case lives at a stop
&lt;/h2&gt;

&lt;p&gt;You do not actually need to sample every pixel under the text to know whether it passes. A gradient is &lt;strong&gt;fully defined by its colour stops&lt;/strong&gt;. Between any two adjacent stops the colour moves smoothly from one to the other, and contrast against a fixed text colour changes smoothly with it. There is no hidden spike in the middle.&lt;/p&gt;

&lt;p&gt;That means the &lt;strong&gt;lowest&lt;/strong&gt; contrast, the worst case, has to occur &lt;em&gt;at one of the stops&lt;/em&gt;. So the whole question reduces to: compute the contrast between the text colour and each stop, take the smallest, and compare it to the threshold. If the worst stop passes, the text passes across the entire gradient. If the worst stop fails, you have a real failure with a real number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WCAG contrast maths
&lt;/h2&gt;

&lt;p&gt;The thresholds come from WCAG &lt;strong&gt;1.4.3 Contrast (Minimum)&lt;/strong&gt;: a ratio of at least &lt;strong&gt;4.5:1&lt;/strong&gt; for normal text, or &lt;strong&gt;3:1&lt;/strong&gt; for large text (24px and up, or 18.66px and up when bold). The ratio itself is built from relative luminance.&lt;/p&gt;

&lt;p&gt;Each channel is linearised, then weighted:&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="c1"&gt;// 0–255 channel → linear light&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;/=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.03928&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;12.92&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mf"&gt;0.055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;1.055&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// relative luminance&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.2126&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;lin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.7152&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;lin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.0722&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;lin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// contrast ratio between two colours&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contrast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same formula axe uses for solid backgrounds. We are not changing how contrast is judged, only giving it the right colours to judge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The algorithm, step by step
&lt;/h2&gt;

&lt;p&gt;For each element axe left as "needs review" for colour contrast:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the computed &lt;code&gt;color&lt;/code&gt; (the text) and the font size and weight, which set the required ratio (4.5 or 3).&lt;/li&gt;
&lt;li&gt;Walk up the ancestors to find the nearest element whose computed &lt;code&gt;background-image&lt;/code&gt; actually contains &lt;code&gt;gradient(&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pull the colour stops out of that value. Browsers compute keywords and hex to &lt;code&gt;rgb()&lt;/code&gt; / &lt;code&gt;rgba()&lt;/code&gt;, so the stops are already concrete numbers.&lt;/li&gt;
&lt;li&gt;Compute the contrast between the text colour and each stop, and keep the &lt;strong&gt;minimum&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If that worst-case ratio is below the requirement, it is a genuine failure. If it clears the bar, the text passes across the whole gradient, so the "needs review" can be dropped entirely.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &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;stop&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stop&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="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// real failure, e.g. "lowest-contrast point is 1.37:1, below the required 3:1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is that a headline which used to come back as an unknown now comes back as "fails at 1.37:1", with the exact element, which is something a developer can actually act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops: the honest limits
&lt;/h2&gt;

&lt;p&gt;This resolves &lt;strong&gt;opaque CSS gradients&lt;/strong&gt;, and deliberately nothing more. Two cases are left as "needs review" on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Background images.&lt;/strong&gt; If the background is a &lt;code&gt;url()&lt;/code&gt; photo, the effective colour under the text cannot be derived from CSS. That genuinely needs pixel sampling, which is a separate problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semi-transparent stops.&lt;/strong&gt; If any stop has an alpha below 1, the real colour depends on whatever sits behind it, so the stops alone are not enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also does not try to account for text shadows, blend modes, or text that overlaps a gradient edge. The rule we hold to is simple: resolve what can be proven from the CSS, and honestly flag the rest for a human. Claiming more than the maths supports would just be a prettier version of the "needs review" problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;Gradients are everywhere in modern design, and they tend to sit behind the text that matters most. Leaving all of it as "needs review" pushes the single most visible contrast decision onto a manual pass that often never happens. Resolving the opaque-gradient case turns a common blind spot into a concrete pass or fail, and it is the kind of detail that separates a tool that runs axe from one that does something useful with the output. You can see it on your own pages with the &lt;a href="https://accessibilityscanner.app/" rel="noopener noreferrer"&gt;free scan&lt;/a&gt;, or wire it into CI with the &lt;a href="https://accessibilityscanner.app/integrations" rel="noopener noreferrer"&gt;CLI and GitHub Action&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does axe-core mark gradient text as "needs review"?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because its contrast check reads a solid &lt;code&gt;background-color&lt;/code&gt;, and a gradient is set via &lt;code&gt;background-image&lt;/code&gt;. With no single background value, axe correctly returns the result as incomplete rather than guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is checking the gradient stops enough?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Contrast against a fixed text colour changes smoothly between stops, so the lowest contrast must occur at a stop. Checking each stop and taking the worst case covers the entire gradient without sampling pixels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work for background images?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The effective colour under text on a photo cannot be derived from CSS, so image backgrounds (and semi-transparent gradients) are left as "needs review". Resolving those needs pixel sampling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What contrast ratios are required?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;WCAG 1.4.3 requires 4.5:1 for normal text and 3:1 for large text (24px and up, or 18.66px and up if bold).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build a few products. This one is &lt;a href="https://accessibilityscanner.app" rel="noopener noreferrer"&gt;Accessibility Scanner&lt;/a&gt;, a free, honest WCAG scanner with no overlay and no fake compliance badge.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>css</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Turning a web tool into a zero-dependency MCP server</title>
      <dc:creator>Chris Morris</dc:creator>
      <pubDate>Fri, 03 Jul 2026 21:44:41 +0000</pubDate>
      <link>https://dev.to/chris_morris/turning-a-web-tool-into-a-zero-dependency-mcp-server-21ca</link>
      <guid>https://dev.to/chris_morris/turning-a-web-tool-into-a-zero-dependency-mcp-server-21ca</guid>
      <description>&lt;p&gt;I run &lt;a href="https://domainintel.app" rel="noopener noreferrer"&gt;DomainIntel&lt;/a&gt;, a small web app that analyzes any&lt;br&gt;
domain: WHOIS, DNS, SSL/TLS, HTTP security headers, blocklist reputation, and&lt;br&gt;
subdomain discovery. The analysis engine was already there as a set of Node&lt;br&gt;
modules behind an Express API. The question was: how do I let AI agents use it&lt;br&gt;
directly, without standing up a whole new service?&lt;/p&gt;

&lt;p&gt;The answer was the &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;&lt;br&gt;
(MCP). This post is the practical, gotcha-heavy version of how I shipped it as&lt;br&gt;
&lt;code&gt;npx -y @domainintel/mcp&lt;/code&gt; — a single file with no runtime dependencies.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;MCP servers expose "tools" an agent can call. I wrapped each analyzer as one:&lt;br&gt;
&lt;code&gt;whois_lookup&lt;/code&gt;, &lt;code&gt;dns_records&lt;/code&gt;, &lt;code&gt;ssl_certificate&lt;/code&gt;, &lt;code&gt;security_headers&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;domain_reputation&lt;/code&gt;, &lt;code&gt;subdomain_discovery&lt;/code&gt;, and a &lt;code&gt;full_domain_report&lt;/code&gt; that runs&lt;br&gt;
everything and returns an overall score. Each takes a &lt;code&gt;domain&lt;/code&gt; and returns&lt;br&gt;
structured JSON. The whole server is ~150 lines on top of the existing analyzers&lt;br&gt;
and the MCP SDK's stdio transport.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't the MCP code. It was packaging.&lt;/p&gt;
&lt;h2&gt;
  
  
  Goal: &lt;code&gt;npx&lt;/code&gt; with no install friction
&lt;/h2&gt;

&lt;p&gt;I wanted a user to run &lt;code&gt;npx -y @domainintel/mcp&lt;/code&gt; and have it work — no clone, no&lt;br&gt;
&lt;code&gt;npm install&lt;/code&gt; of a dependency tree. That means bundling the server &lt;em&gt;and&lt;/em&gt; the&lt;br&gt;
analyzer graph it reuses into one self-contained file. I used esbuild. Four&lt;br&gt;
things bit me; all are general to "bundle a Node CLI that reuses CommonJS code&lt;br&gt;
into an ESM binary."&lt;/p&gt;
&lt;h3&gt;
  
  
  1. createRequire so a bundler can follow your imports
&lt;/h3&gt;

&lt;p&gt;My server originally pulled the analyzers in with &lt;code&gt;createRequire&lt;/code&gt;:&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;require&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRequire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;analyzeDns&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../lib/analyzers/dns&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;esbuild can't follow a runtime &lt;code&gt;createRequire&lt;/code&gt; call — it only sees static&lt;br&gt;
imports. So nothing got bundled. The fix was to switch to static default&lt;br&gt;
imports, which Node's ESM loader maps to a CommonJS module's &lt;code&gt;module.exports&lt;/code&gt;:&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;dnsPkg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../lib/analyzers/dns.js&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;analyzeDns&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dnsPkg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now esbuild follows the graph, and &lt;code&gt;node server.mjs&lt;/code&gt; still works in dev.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Node built-ins under ESM output: "Dynamic require of 'net' is not supported"
&lt;/h3&gt;

&lt;p&gt;One dependency (&lt;code&gt;whois&lt;/code&gt;) calls &lt;code&gt;require('net')&lt;/code&gt; internally. In esbuild's ESM&lt;br&gt;
output there's no &lt;code&gt;require&lt;/code&gt;, so it throws at runtime. The fix is a banner that&lt;br&gt;
defines one via &lt;code&gt;createRequire&lt;/code&gt;, which esbuild's shim then uses for built-ins:&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="nx"&gt;banner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#!/usr/bin/env node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;import { createRequire as __cr } from 'module';&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;const require = __cr(import.meta.url);&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. stdout belongs to the protocol — don't log to it
&lt;/h3&gt;

&lt;p&gt;MCP over stdio uses stdout for the JSON-RPC stream. My analyzers' shared logger&lt;br&gt;
wrote to a &lt;code&gt;logs/&lt;/code&gt; directory, which is also wrong for a globally-installed CLI&lt;br&gt;
(it would try to mkdir inside the npm install dir). I swapped it at build time&lt;br&gt;
for a stderr-only stub using an esbuild &lt;code&gt;onResolve&lt;/code&gt; plugin, so the real app keeps&lt;br&gt;
file logging and the bundle stays quiet on stdout:&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="nx"&gt;build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onResolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/utils&lt;/span&gt;&lt;span class="se"&gt;[\\/]&lt;/span&gt;&lt;span class="sr"&gt;errorLogger&lt;/span&gt;&lt;span class="se"&gt;(\.&lt;/span&gt;&lt;span class="sr"&gt;js&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. The small stuff
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two shebangs.&lt;/strong&gt; The entry file had &lt;code&gt;#!/usr/bin/env node&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; the banner
added one, so the bundle's line 2 was an invalid &lt;code&gt;#&lt;/code&gt;. Drop it from the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;"type": "module"&lt;/code&gt; + CommonJS.&lt;/strong&gt; When I vendored the analyzers into a
standalone repo whose &lt;code&gt;package.json&lt;/code&gt; had &lt;code&gt;"type": "module"&lt;/code&gt;, esbuild treated
the CJS &lt;code&gt;.js&lt;/code&gt; files as ESM and choked on &lt;code&gt;module.exports&lt;/code&gt;. Removing
&lt;code&gt;"type": "module"&lt;/code&gt; (the &lt;code&gt;.mjs&lt;/code&gt; entry stays ESM by extension) fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dependency going ESM.&lt;/strong&gt; &lt;code&gt;whois@2.16&lt;/code&gt; switched to ESM, which broke the
non-bundled &lt;code&gt;require('whois')&lt;/code&gt; dev path. Pinning to &lt;code&gt;2.15.0&lt;/code&gt; kept both the dev
path and a reproducible bundle.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;mcp/dist/server.mjs&lt;/code&gt; is one ~1.7 MB file, zero runtime dependencies. Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add domainintel &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @domainintel/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your agent can run &lt;em&gt;"give me a full report on stripe.com"&lt;/em&gt; and get&lt;br&gt;
structured results instead of shelling out to &lt;code&gt;dig&lt;/code&gt;/&lt;code&gt;whois&lt;/code&gt; and parsing text.&lt;/p&gt;

&lt;p&gt;If you maintain a tool with a usable core, wrapping it as an MCP server is a&lt;br&gt;
small lift, and bundling it to a single file makes it genuinely one-command to&lt;br&gt;
adopt. Source is on &lt;a href="https://github.com/Bishop81/domainintel-mcp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; —&lt;br&gt;
happy to answer questions about any of the above.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Almost half the WordPress plugin directory has not been updated in two years</title>
      <dc:creator>Chris Morris</dc:creator>
      <pubDate>Sat, 27 Jun 2026 22:57:10 +0000</pubDate>
      <link>https://dev.to/chris_morris/almost-half-the-wordpress-plugin-directory-has-not-been-updated-in-two-years-26i8</link>
      <guid>https://dev.to/chris_morris/almost-half-the-wordpress-plugin-directory-has-not-been-updated-in-two-years-26i8</guid>
      <description>&lt;p&gt;I indexed the WordPress.org plugin directory and measured how well it is maintained. The headline: of the 57,383 plugins in the index, 45.6 percent have not shipped an update in 24 or more months. Data is as of June 2026.&lt;/p&gt;

&lt;p&gt;Here is the full staleness picture, measured against plugins that report an update date:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;65 percent: no update in 6+ months&lt;/li&gt;
&lt;li&gt;56 percent: no update in 12+ months&lt;/li&gt;
&lt;li&gt;45.6 percent: no update in 24+ months&lt;/li&gt;
&lt;li&gt;41.2 percent: no update in 36+ months&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Not updated in two years" is the common line for calling a plugin abandoned, and WordPress.org uses a similar signal when it warns you on a plugin page. So nearly half the directory carries that flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting part is who goes stale
&lt;/h2&gt;

&lt;p&gt;Abandonment is not evenly spread. It tracks inversely with install count:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Active installs&lt;/th&gt;
&lt;th&gt;Abandoned (no update in 24+ months)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1K to 10K&lt;/td&gt;
&lt;td&gt;23.9 percent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10K to 100K&lt;/td&gt;
&lt;td&gt;10.1 percent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100K to 1M&lt;/td&gt;
&lt;td&gt;2 percent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M+&lt;/td&gt;
&lt;td&gt;0 percent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The very large plugins are maintained. The risk and the opportunity sit in the middle. 192 plugins with 10,000 or more active installs have not been updated in over two years. Each one is a real user base running code nobody is shipping fixes for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a builder should care
&lt;/h2&gt;

&lt;p&gt;A plugin with 30,000 installs and no maintainer is not a dead end. It is a ready-made market. The users already exist, the search demand already exists, and the incumbent has stopped competing. The work is not inventing a category. It is shipping a maintained, modern version of something people already rely on.&lt;/p&gt;

&lt;p&gt;That is a very different starting point from a cold launch. You are not asking whether anyone wants the thing. You are looking at the install count and reading the recent one-star reviews to find out exactly what to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use this
&lt;/h2&gt;

&lt;p&gt;If you are looking for a side project or a small product to build, abandoned mid-size plugins are a practical place to start. Pick a niche you understand, find a plugin in it with a stuck user base, read its support forum, and confirm the gap before you write any code.&lt;/p&gt;

&lt;p&gt;The full report, with the named lists of the most-installed abandoned plugins, the most-installed low-rated plugins, and the largest plugins with the weakest support, is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wpgoldmine.io/research/state-of-wordpress-plugin-opportunities-2026" rel="noopener noreferrer"&gt;The State of WordPress Plugin Opportunities 2026&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The numbers above are measured from live WordPress.org data and the method is stated on the report.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>data</category>
    </item>
  </channel>
</rss>
