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:
thisBinding: Insideonclick="copyLink(this)",thisrefers to the DOM element itself.- The Implicit
eventArgument: Inline handlers have access to a localeventvariable without declaring it. return falseBehavior: Returningfalsefrom an inline handler automatically callsevent.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:
focusandblurmouseenterandmouseleaveloadanderrorscroll
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 use
onclick="window.print()", the handler is hashed and emitted only once inHANDLERS. - Region Masking: Code blocks (
<code>,<pre>),<textarea>,<script>strings, and HTML comments are masked prior to extraction, so tutorials discussingonclick="..."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@latestIf 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.