Right after publishing v0.1.3 of astro-strict-csp to handle Shiki inline styles and <head> theme scripts, I remembered another annoying CSP workaround sitting in my own website's codebase: inline event attributes.

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

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

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

Zero 'unsafe-inline' Without Rewriting HTML

Instead of forcing you to refactor components or weaken your policy with'unsafe-hashes', astro-strict-csp extracts inline event handlers during astro:build:done and rewrites them intodata-csp-on-* attributes.

What you write in your Astro template or Markdown:

<button onclick="window.print()" class="btn">Print Recipe</button>
<a href="#" onclick="copyLink(this); return false;">Share</a>

What actually ships in production HTML:

<button data-csp-on-click="evt-a1b2c3" class="btn">Print Recipe</button>
<a href="#" data-csp-on-click="evt-d4e5f6">Share</a>

<!-- Automatically hashed by the SHA-384 SRI pipeline -->
<script type="module" src="/_astro/events-8f92a1.js" integrity="sha384-..." crossorigin="anonymous"></script>

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

Preserving Native Browser Semantics

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

  1. this Binding: Inside onclick="copyLink(this)",this refers to the DOM element itself.
  2. The Implicit event Argument: Inline handlers have access to a local event variable without declaring it.
  3. return false Behavior: Returning falsefrom an inline handler automatically calls event.preventDefault().

To mirror native browser behavior without attaching hundreds of individual listeners across the DOM, astro-strict-csp generates a single delegated dispatcher:

// Generated in /_astro/events-[hash].js
const HANDLERS = {
  'evt-a1b2c3': function(event) { window.print(); },
  'evt-d4e5f6': function(event) { copyLink(this); return false; }
};

['click'].forEach(evt => {
  document.addEventListener(evt, e => {
    for (const el of (e.composedPath ? e.composedPath() : [])) {
      if (el.getAttribute) {
        const id = el.getAttribute('data-csp-on-' + evt);
        if (id && HANDLERS[id]) {
          // Mirrors native inline event semantics:
          // 1. `this` is bound to the element via .call(el, e)
          // 2. `event` is passed as the parameter
          // 3. `return false` prevents default browser action
          if (HANDLERS[id].call(el, e) === false) {
            e.preventDefault();
          }
          break;
        }
      }
    }
  }, { capture: true });
});

The { capture: true } Trick for Non-Bubbling Events

Most naive event delegation scripts attach a normal bubbling listener todocument and check e.target. That works fine forclick or keydown, but it fails completely onnon-bubbling events like:

  • focus and blur
  • mouseenter and mouseleave
  • load and error
  • scroll

Because those events do not bubble up the DOM tree, a normal listener ondocument never hears them.

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

And just like the CSS extractor in v0.1.0:

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

Ditching Custom CI Scripts for Google's release-please

Alongside the v0.2.0 feature work, I cleaned up the repository's release pipeline.

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 CHANGELOG.md, mismatch a git tag, or fight token permissions.

I replaced the custom CI logic with Google's release-please. Now, because the repo already follows strict Conventional Commits(feat:, fix:, perf:), release-pleaseautomatically maintains a living Release PR. Every time a commit lands onmain, 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.

Try It Out

npm install astro-strict-csp@latest

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 GitHub Issues.