sandstorm / cookiepunch
Block elements like iframes and scripts before the markup reaches the browser and provide a dialog in the browser to enable them again.
Package info
github.com/sandstorm/Sandstorm.CookiePunch
Language:CSS
Type:neos-package
pkg:composer/sandstorm/cookiepunch
Requires
- neos/neos: ^9.0
This package is auto-updated.
Last update: 2026-05-29 13:38:54 UTC
README
A Neos package that blocks elements like <script> and <iframe> server-side — before the markup reaches the browser — and ships Klaro as the consent UI to selectively unblock them once the user agrees.
Contents
- Features
- How it works
- Installation
- Minimal setup
- Basic Configuration and Usages
- Advanced Usages
- Full list of consent / service options
- Supported tags
- Pattern reference
- How blocking transforms markup
- Blocking a rendered Fusion subtree
- Adding a contextual consent for non-iframe elements
- Let the editor choose a service from the inspector
- Let the editor change the text of the consent
- Caching the consent
- Privacy URL alternatives
- Manual styling
- Translations
- Conditional Rendering of Services in the Consent Modal
- Editor-defined dynamic services
- Per-service lifecycle callbacks (
onInit/onAccept/onDecline) - Contextual Consent Only Mode
- Troubleshooting
- Migration guide
- Contributing
Features
- Eel helpers to block elements (scripts, iframes, and more) before the markup is sent to the client.
- Eel helper to place contextual consents anywhere in the markup.
- YAML configuration with patterns for targeting tags in the markup.
- Contextual-consent-only mode — no initial banner / modal.
- Localization via YAML and/or Fusion.
- Data source providing all services as a dropdown in the inspector.
- A polished cookie-consent provided by Klaro, bundled directly with this package:
- Unblocking of elements after consent.
- Contextual consents — temporarily or permanently unblock content from the element itself, without opening the modal.
How it works
CookiePunch combines server-side markup rewriting with the client-side Klaro consent UI. On every render, the CookiePunch.blockTags(...) Eel helper walks the markup and breaks the configured tags: src becomes data-src, type becomes data-type, <script> tags get type="text/plain", and a data-name="<service>" attribute is added when a service is in play. The browser refuses to fetch or execute the broken tags. Klaro then reads data-name, shows a consent UI, and on accept swaps the attributes back so the browser fetches and runs the original content.
The service identifier is the single string that ties (a) the YAML/inline blocking config, (b) the data-name in the rewritten markup, and (c) the switch in the Klaro modal together. Keep it consistent across all three and the rest follows.
# Data flow: server-side rewrite → browser → consent → restored markup
Fusion render
│
▼ CookiePunch.blockTags / @process.blockTags
[rewritten markup] <script type="text/plain" data-src="…" data-name="myservice">…</script>
│
▼ shipped to browser
[Klaro JS] reads data-name → renders switch → user consents
│
▼ Klaro restores attributes
<script type="application/javascript" src="…"> ← browser fetches & runs
Installation
# bash — from the application root
composer require sandstorm/cookiepunch
This puts the dependency in the outer composer.json / composer.lock (usually in your repo root or /app).
Important: If you want to declare CookiePunch settings inside one of your Flow packages, also add the composer dependency to that package's
composer.jsonto ensure correct Flow package and configuration loading order.
// DistributionPackages/Your.SitePackage/composer.json { "require": { "sandstorm/cookiepunch": "*" } }
For exhaustive references, see FullConsentConfig.yaml and FullServiceConfig.yaml.
Minimal setup
Two files get a working consent modal on every page. Drop them into your site package and reload:
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Neos.Neos:Page) {
head.javascripts.cookiepunchConsent = Sandstorm.CookiePunch:Consent
@process.blockTags = ${CookiePunch.blockTags(["iframe","script"], value, !node.context.inBackend)}
}
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: purposes: essential: title: Essential description: Required for basic functionality. services: {} blocking: tagPatterns: script: # never block Neos' own scripts, or the backend breaks "Packages/Neos.Neos": block: false
This blocks every <iframe> and <script> (except Neos' own), shows the consent modal, and is enough to verify the install. The 6 Steps below layer in real services, pattern matching, and editor integrations. See also Examples/Settings.CookiePunch.Basic.yaml.
Basic Configuration and Usages
Step 1: Adding the consent-modal
Drop a CookiePunch.fusion file in your site package:
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Neos.Neos:Page) {
head.javascripts.cookiepunchConsent = Sandstorm.CookiePunch:Consent
@process.blockTags = ${CookiePunch.blockTags(["iframe","script"], value, !node.context.inBackend)}
}
This adds the consent modal and starts blocking every <iframe> and <script>. The !node.context.inBackend flag keeps the Neos backend functional.
Reload the page — it will likely look broken. Open the DevTools console and call klaro.show() to confirm Klaro is loaded; the next steps fix the breakage. For the full list of tag names you can pass to blockTags, see Supported tags.
Step 2: Always allow your own JavaScript
Some scripts (main.js, app.js, …) must always be allowed or your site won't work. You most likely have a Fusion prototype that bundles them — something like Vendor.Site:HeaderAssets — and that's the natural place to attach neverBlockTags:
// Resources/Private/Fusion/Component/HeaderAssets.fusion
prototype(Vendor.Site:HeaderAssets) < prototype(Neos.Fusion:Component) {
renderer = afx`
<script src={StaticResource.uri('Vendor.Site', 'JavaScript/main.js')}></script>
<script src={StaticResource.uri('Vendor.Site', 'JavaScript/menu.js')}></script>
`
@process.neverBlockTags = ${CookiePunch.neverBlockTags(["script"], value)}
}
For a one-off script tag, attach the helper directly:
// Resources/Private/Fusion/YourComponent.fusion
renderer = afx`
<script src={props.src} type="application/javascript" @process.neverBlockTags={CookiePunch.neverBlockTags(["script"], value)}></script>
`
The same effect can be achieved via a YAML pattern (Step 3), but the helper makes the intent — I checked, this script is required — explicit at the call site.
Step 3: Blocking via YAML config
Create Configuration/Settings.CookiePunch.yaml. Tip: register the package's schema.json in your IDE for auto-completion.
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: purposes: mediaembeds: title: Media Embeds description: Some Description services: anchor: title: Anchor FM description: Podcast Player purposes: - mediaembeds blocking: tagPatterns: script: "Packages/Neos.Neos": block: false "Packages/Vendor.ExampleLibrary": block: false iframe: "https://anchor.fm": service: anchor
The config has two parts: consent drives the Klaro UI (purposes group services), while blocking matches tags by substring pattern and either lets them through (block: false), blocks them permanently (block: true), or attaches them to a service so the user can allow them via consent (service: anchor).
Given the config above, this input markup:
<!-- Markup as rendered by Fusion before CookiePunch processes it --> <script src="/_Resources/Static/Packages/Neos.Neos/JavaScript/main.js"></script> <script src="/_Resources/Static/Packages/Vendor.ExampleLibrary/slider.js"></script> <iframe src="https://anchor.fm/embed/episodes/foo"></iframe> <script src="https://cdn.example.com/tracker.js"></script>
…is transformed into this output markup:
<!-- Markup after CookiePunch processing --> <script src="…/Packages/Neos.Neos/…/main.js"></script> <!-- untouched --> <script src="…/Packages/Vendor.ExampleLibrary/slider.js"></script> <!-- untouched --> <iframe data-src="https://anchor.fm/embed/episodes/foo" data-name="anchor"></iframe> <!-- broken; Klaro can restore via the "anchor" service --> <script type="text/plain" data-src="https://cdn.example.com/tracker.js" data-type="text/javascript"></script> <!-- broken; no service → permanently blocked -->
For substring-matching rules, the wildcard *, and the difference between block: false / block: true / service: …, see Pattern reference. For the exact attribute rewrites done to a "broken" tag, see How blocking transforms markup.
Step 4: Providing a link to your privacy statement
The default URL is /privacy. Override it with a string for the simplest case:
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: privacyPolicyUrl: /imprint/privacy
For most projects you'll want editors to pick the privacy page from the inspector. Add a reference property on your Homepage NodeType:
# NodeTypes/Document/Homepage/Document.Homepage.yaml "Vendor.Site:Document.Homepage": properties: privacyPolicyUrl: type: reference ui: label: "Privacy page" inspector: group: "settings" editorOptions: nodeTypes: ["Neos.Neos:Document"]
…and point CookiePunch at it. site is already the Homepage, so no q(site).find(...) is needed:
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Sandstorm.CookiePunch:Config) {
consent.privacyPolicyUrl = ${q(site).property("privacyPolicyUrl")}
consent.privacyPolicyUrl.@process.convert = Neos.Neos:ConvertUris
}
For other approaches (XLIFF translation key, dedicated PrivacyPage node type), see Privacy URL alternatives.
Step 5: Let the user reopen the consent modal later
Place a link in Neos (e.g. in your privacy statement) with href="#open_cookie_punch_modal". A click handler picks it up and opens the modal — the browser does not reload because event.preventDefault() is called internally.
Alternatively call klaro.show() from your own JavaScript.
Step 6: Styling
Override the CSS variables Klaro exposes via YAML:
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: styling: font-family: "Work Sans, Helvetica Neue, Helvetica, Arial, sans-serif" green1: "#00aa00" border-radius: "0"
For the full variable list, see Examples/Settings.CookiePunch.Styling.yaml. To replace Klaro's CSS entirely with your own, see Manual styling.
Advanced Usages
Full list of consent / service options
Most inline comments are copied directly from the annotated config.js of Klaro for convenience.
Supported tags
CookiePunch.blockTags(...) and CookiePunch.neverBlockTags(...) accept any of these tag names:
iframe, script, audio, video, source, track, img, embed, input.
The same set is allowed as keys under Sandstorm.CookiePunch.blocking.tagPatterns in YAML — see schema.json.
Pattern reference
Patterns under tagPatterns.<tagName> are matched against the raw rendered tag string with strpos() — i.e. it's a substring match. Anything in the tag (src URL, attribute name, attribute value, …) is fair game.
The Packages/Neos.Neos pattern, for example, matches all of these:
<!-- Example HTML matched by the "Packages/Neos.Neos" pattern --> <script src="/foo/bar/Packages/Neos.Neos/baz/index.js"/> <script data-foo="Neos.Neos"/> <script Neos.Neosisawesome src="/some/source/main.js"/>
Each pattern carries one of three actions:
# Configuration/Settings.CookiePunch.yaml "Packages/Neos.Neos": block: false # always allowed "https://really-stuff.bad": block: true # always blocked, the consent cannot allow it "https://anchor.fm": service: anchor # blocked, but the user can allow via consent
Wildcard ("*")
The reserved key "*" flips the default for a tag name. Use sparingly — it defeats the purpose of documenting which services are in use.
The most defensible case is <img>: by default you usually do not want every image blocked, only specific tracking pixels. Add img to the blockTags call so CookiePunch processes it, then flip the default:
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Neos.Neos:Page) {
head.javascripts.cookiepunchConsent = Sandstorm.CookiePunch:Consent
@process.blockTags = ${CookiePunch.blockTags(["iframe","script", "img"], value, !node.context.inBackend)}
}
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: blocking: tagPatterns: img: "*": block: false "tracking-pixel-url": service: myservice
How blocking transforms markup
When CookiePunch breaks a tag, it does so by attribute rewriting — nothing is removed from the DOM:
src→data-src, so the browser doesn't fetch the resource.- For
<script>tags only: the originaltypeis moved todata-typeand the live attribute is replaced withtype="text/plain", so the browser refuses to execute the script. - If the matching pattern points at a service,
data-name="<service>"is added. Klaro reads this attribute, presents a contextual consent, and on accept swaps thedata-*attributes back to their live counterparts.
A tag with no data-name stays broken forever — there is no service to drive its restoration.
Blocking a rendered Fusion subtree
An already-blocked piece of markup is not re-blocked when running the Eel helpers later on Neos.Neos:Page. This means we can hook into specific plugins to block them and attach them to a service.
This is especially useful for inline <script>...</script> tags that cannot be matched by a URL pattern.
// Resources/Private/Fusion/Plugin/FooTube.fusion — plugin implementation
prototype(Vendor.Plugin.FooTube:Embed) < prototype(Neos.Fusion:Component) {
renderer = afx`
<div>
<iframe src="..."></iframe>
<script type="text/javascript">...</script>
</div>
`
}
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Vendor.Plugin.FooTube:Embed) {
// tags in this part of the tree will be blocked first
@process.blockTags = ${CookiePunch.blockTags(["iframe","script"], value, !node.context.inBackend, "footube")}
}
prototype(Neos.Neos:Page) {
head.javascripts.cookiepunchConsent = Sandstorm.CookiePunch:Consent
// at last, all remaining tags will be blocked according to the config
// already blocked tags will be ignored
@process.blockTags = ${CookiePunch.blockTags(["iframe","script"], value, !node.context.inBackend)}
}
Adding a contextual consent for non-iframe elements
When blocking a <script> you may end up with a broken UI as some styles or markup never run. Use the helper below to wrap parts of the rendered Fusion tree so Klaro can swap the broken content for a contextual consent.
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Vendor.Plugin.FooTube:Embed) {
@process.blockTags = ${CookiePunch.blockTags(["script"], value, !node.context.inBackend, "footube")}
@process.addContextualConsent = ${CookiePunch.addContextualConsent("footube", value, !node.context.inBackend)}
}
Another use case: <audio> or <video> tags (with or without nested <source> tags). You may want to block them so a visitor's IP address isn't sent to a third-party server before consent.
// Resources/Private/Fusion/Component/ThirdpartyAudio.fusion
prototype(Vendor:Component.ThirdpartyAudio) < prototype(Neos.Fusion:Component) {
thirdpartySrc = ''
renderer = afx`
<audio>
<source src={props.thirdpartySrc}/>
</audio>
`
@process.blockTags = ${CookiePunch.blockTags(["source"], value, !node.context.inBackend, "thirdpartymedia")}
@process.addContextualConsent = ${CookiePunch.addContextualConsent("thirdpartymedia", value, !node.context.inBackend)}
}
Let the editor choose a service from the inspector
If editors can place HTML (e.g. via a Vendor.Site:Content.Html node type), they can introduce markup that sets cookies. With the default config, CookiePunch blocks this content — and if the markup matches no YAML pattern, it stays blocked permanently.
Add Sandstorm.CookiePunch:Mixin.ConsentServices to the affected node type to expose a service dropdown in the inspector:
# NodeTypes/Content/Html/Content.Html.yaml "Vendor.Site:Content.Html": superTypes: "Sandstorm.CookiePunch:Mixin.ConsentServices": true
Then wire the chosen service into the actual blocking:
// NodeTypes/Content/Html/Content.Html.fusion
prototype(Vendor.Site:Content.Html) {
@process.blockTags = ${CookiePunch.blockTags(["iframe","script"], value, !node.context.inBackend, q(node).property("consentServices"))}
// Wrap the html element with `<div data-name="myservice">...</div>` to make sure
// the contextual consent is displayed correctly
@process.contextualConsent = ${CookiePunch.addContextualConsent(q(node).property("consentServices"), value, !node.context.inBackend)}
}
Let the editor change the text of the consent
All texts of the consent notice and modal live in the Fusion prototype Sandstorm.CookiePunch:Config.Translations. Each key maps to a Klaro string — ok, decline, consentNotice.description, consentNotice.learnMore, consentModal.title, consentModal.description, privacyPolicy.text, contextualConsent.*, and more. The complete list is in Resources/Private/Fusion/Config.Translations.fusion.
You can override any of these from Fusion. To let editors maintain them, wire the paths to inspector properties on a dedicated node.
1. A node holding the editable texts
# NodeTypes/Document/CookieConsentTexts/Document.CookieConsentTexts.yaml "Vendor.Site:Document.CookieConsentTexts": superTypes: "Neos.Neos:Document": true ui: label: "Cookie consent texts" icon: icon-cookie inspector: groups: consent: label: "Cookie consent" properties: ok: type: string ui: label: "Accept button" inspector: { group: consent } decline: type: string ui: label: "Decline button" inspector: { group: consent } consentNoticeDescription: type: string ui: label: "Notice text (use {imprint} for the imprint link)" inspector: group: consent editor: Neos.Neos/Inspector/Editors/TextAreaEditor consentNoticeLearnMore: type: string ui: label: "Notice 'learn more' link" inspector: { group: consent } consentModalTitle: type: string ui: label: "Modal title" inspector: { group: consent } consentModalDescription: type: string ui: label: "Modal text" inspector: group: consent editor: Neos.Neos/Inspector/Editors/TextAreaEditor privacyPolicyText: type: string ui: label: "Privacy-policy line (use {imprint} for the imprint link)" inspector: group: consent editor: Neos.Neos/Inspector/Editors/TextAreaEditor imprint: type: reference ui: label: "Imprint page" inspector: group: consent editorOptions: nodeTypes: ["Neos.Neos:Document"]
2. Wire the properties into Config.Translations
Use || CookiePunchConfig.translate(...) for keys where an empty inspector field should fall back to the bundled Klaro translation instead of blanking the string:
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Sandstorm.CookiePunch:Config.Translations) {
@context._texts = ${q(site).find('[instanceof Vendor.Site:Document.CookieConsentTexts]').get(0).properties}
// Resolve the referenced imprint node to an <a> tag we can splice into the text
@context._imprintLink = Neos.Neos:NodeLink {
node = ${_texts.imprint}
}
ok = ${_texts.ok || CookiePunchConfig.translate("Sandstorm.CookiePunch.translations.ok")}
decline = ${_texts.decline || CookiePunchConfig.translate("Sandstorm.CookiePunch.translations.decline")}
consentNotice {
description = ${String.replace(_texts.consentNoticeDescription, '{imprint}', _imprintLink)}
learnMore = ${_texts.consentNoticeLearnMore || CookiePunchConfig.translate("Sandstorm.CookiePunch.translations.consentNotice.learnMore")}
}
consentModal {
title = ${_texts.consentModalTitle}
description = ${_texts.consentModalDescription}
}
privacyPolicy {
text = ${String.replace(_texts.privacyPolicyText, '{imprint}', _imprintLink)}
}
// Keys you do not override fall back to the bundled Klaro translations automatically.
}
3. Enable HTML rendering for the descriptions
Neos.Neos:NodeLink renders a full <a href="…">…</a> tag, so any text containing the {imprint} substitution now contains HTML. Allow Klaro to render it:
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: # Renders the descriptions of the consent modal/notice as HTML. Use with care. htmlTexts: true
4. Flush the cache when editors change the texts
The override reads from q(site).find(...) and is rendered inside the cached Neos.Neos:Page. Add Neos.Caching.nodeTypeTag('Vendor.Site:Document.CookieConsentTexts') to the consent cache — see Caching the consent.
Notes & caveats
- The full set of overridable paths is in
Config.Translations.fusion. - An empty inspector property silently blanks the bundled default. Use the
|| CookiePunchConfig.translate(...)fallback for any string where that would be a regression. Neos.Neos:NodeLinkrenders an<a>tag —htmlTexts: trueis mandatory wherever you substitute it in.- The
@cacheentry tag on the texts node type is not optional; without it, edits don't propagate. See Caching the consent. - For multi-language sites where the text varies by locale, XLIFF (see Translations) is the better tool. Inspector properties suit editor-owned wording, not translator-owned.
Caching the consent
Sandstorm.CookiePunch:Consent ships without a @cache block. It is rendered inside the cached Neos.Neos:Page, so any dynamic read it makes — q(site).find(...) for conditional services, dynamic services, or editor-maintained texts — gets baked into each page's cache entry. Without explicit cache tags, editing the source nodes never reaches already-cached pages.
Override the prototype once with the canonical block and merge all the entry tags the rest of your setup needs:
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Sandstorm.CookiePunch:Consent) {
@cache {
mode = 'cached'
entryIdentifier {
node = ${node}
}
entryTags {
1 = ${Neos.Caching.nodeTag(node)}
// Add one numeric key per nodeTypeTag your dynamic reads depend on:
// 2 = ${Neos.Caching.nodeTypeTag('Vendor.Site:Document.CookieConsentTexts')} // editor-maintained consent texts
// 3 = ${Neos.Caching.nodeTypeTag('Vendor.Site:Content.CookieConsentEmbed')} // editor-defined dynamic services
// 4 = ${Neos.Caching.nodeTypeTag('Vendor.Site:Document.RootPage')} // when:-expressions reading site properties
// 5 = ${Neos.Caching.nodeTypeTag('Vendor.Site:Content.YouTube')} // when:-expressions counting content nodes
}
}
}
Why this lives in one place. The entryTags keys must be unique within the block — if you copy the snippet from two Advanced sections that each define entryTags { 1 = ...; 2 = ... }, the later override silently wins and your first feature stops invalidating. Keep one @cache block in your project and add a new numbered tag for each Advanced feature you adopt.
Privacy URL alternatives
Beyond the simple-string and Homepage-property forms shown in Step 4, two other paths are available.
XLIFF translation key
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: privacyPolicyUrl: Vendor.Site:Main:privacyPolicyUrl
Dedicated PrivacyPage node type
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Sandstorm.CookiePunch:Config) {
consent.privacyPolicyUrl = Neos.Neos:NodeUri {
node = ${q(site).find('[instanceof Vendor.Site:Document.PrivacyPage]').get(0)}
}
}
Manual styling
To take full control of the consent UI's CSS, disable the bundled stylesheet and provide your own. Note this couples your styling to Klaro's class names — it can break on package updates.
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Neos.Neos:Page) {
head.javascripts.cookiepunchConsent = Sandstorm.CookiePunch:Consent {
noCSS = true
}
@process.blockTags = ${CookiePunch.blockTags(["iframe","script"], value, !node.context.inBackend)}
}
The original Klaro stylesheet ships at Resources/Private/KlaroCss/klaro.css if you want to fork from it.
Translations
Klaro already provides translations for many languages. They are exposed as XLIFF files in Resources/Private/Translations.
You can override translations by:
- creating your own XLIFF files that override the defaults,
- providing a translation key (e.g.
Vendor.Site:CookiePunch:services.youtube.description) instead of literal text in the YAML config, - overriding the corresponding path in the Fusion prototypes
Sandstorm.CookiePunch:Config.TranslationsorSandstorm.CookiePunch:Config.
Example: translating service labels
Service labels in your Settings.CookiePunch.yaml can be translated like this:
# Configuration/Settings.CookiePunch.yaml services: youtube: title: Youtube description: Vendor.Site:CookiePunch:services.youtube.description
Where:
Vendor.Siteis your site package key,CookiePunchis the name of the XLIFF file containing the translations (any name — must match the file name). See screenshot:
- and inside the file you reference the key after the colon (here:
services.youtube.description):
<!-- Resources/Private/Translations/de/CookiePunch.xlf --> <trans-unit id="services.youtube.description"> <source>Erlaubt die Einbindung von Youtube-Videos.</source> </trans-unit>
Conditional Rendering of Services in the Consent Modal
You can decide at runtime whether a switch should appear in the consent modal:
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: services: youtube: title: Youtube description: ... purposes: - mediaembeds when: "${q(site).find('[instanceof Vendor.Site:Content.YouTube]').count() > 0}" googleAnalytics: title: Google Analytics description: ... purposes: - analytics when: "${q(site).property('googleAnalyticsAccountKey')}"
For a complete example see Examples/Settings.CookiePunch.WithWhenConditions.yaml.
This is useful in multi-site setups, and to prevent unnecessary consent switches when e.g. no YouTube video has ever been added to the content (the Vendor.Site:Content.YouTube node type above stands in for whichever content type embeds a YouTube video in your site).
Notes:
- The
whenvalue must be an Eel expression that evaluates to boolean. - With no
whencondition, the default is${true}— the switch always renders for that service. - When querying the content repository with
q(...), onlysiteis available.documentNodeandnodeare not. - Klaro stores past consent decisions in a cookie, so removing and re-adding e.g. a YouTube video will not re-prompt users who already consented.
Important: every node type referenced in your when expressions needs a matching Neos.Caching.nodeTypeTag(...) on the consent cache (e.g. Vendor.Site:Document.RootPage for q(site).property(...), Vendor.Site:Content.YouTube for q(site).find('[instanceof ...YouTube]').count()). See Caching the consent for the canonical block.
Preventing an empty consent modal
If all when expressions evaluate to false you can hide the modal entirely:
// Resources/Private/Fusion/CookiePunch.fusion
prototype(Sandstorm.CookiePunch:Consent) {
// only render if there is at least one service that has not been filtered out by its 'when' config key
@if.hasServices = ${Array.length(this.servicesRemainingAfterWhenConditions) > 0}
}
Editor-defined dynamic services
Let the editor choose a service from the inspector lets editors pick from a predefined list of services. Sometimes you want them to create a new service on the fly — e.g. a content element where the editor pastes a third-party embed, names the service, and a matching switch appears in the consent automatically.
The trick is to override Sandstorm.CookiePunch:Consent and append dynamically-built services to servicesRemainingAfterWhenConditions (the same property used in Conditional Rendering). The service key is derived by hashing the editor's typed name, so the same value can be used on both the blocking side and the consent side.
1. A content element node type
# NodeTypes/Content/CookieConsentEmbed/Content.CookieConsentEmbed.yaml "Vendor.Site:Content.CookieConsentEmbed": superTypes: "Neos.Neos:Content": true ui: label: "Third-party embed (with consent)" inspector: groups: consent: label: "Cookie consent" properties: serviceName: type: string validation: "Neos.Neos/Validation/NotEmptyValidator": [] ui: label: "Service name (shown in the cookie consent)" inspector: group: consent serviceDescription: type: string ui: label: "Service description" inspector: group: consent embedCode: type: string ui: label: "Embed code (script / iframe)" reloadIfChanged: true inspector: group: consent editor: Neos.Neos/Inspector/Editors/CodeEditor
2. Render and block the element's own markup
The element renders the embed, then blocks it and attaches the contextual consent. The service key is the md5 of the editor's serviceName:
// NodeTypes/Content/CookieConsentEmbed/Content.CookieConsentEmbed.fusion
prototype(Vendor.Site:Content.CookieConsentEmbed) < prototype(Neos.Neos:ContentComponent) {
// derive the service key once; the consent override below MUST hash the same way
@context.serviceKey = ${String.md5(q(node).property('serviceName'))}
renderer = afx`
<div>{String.htmlSpecialCharsDecode(q(node).property('embedCode'))}</div>
`
@process.blockTags = ${CookiePunch.blockTags(["iframe","script"], value, !node.context.inBackend, serviceKey)}
@process.addContextualConsent = ${CookiePunch.addContextualConsent(serviceKey, value, !node.context.inBackend)}
}
3. Register a service for every embed
Override the consent prototype to scan the site for these elements and append one service per distinct name:
// Resources/Private/Fusion/Overrides/CookiePunch.fusion
prototype(Sandstorm.CookiePunch:Consent) {
// recompute the statically-configured services (we cannot self-reference
// servicesRemainingAfterWhenConditions, so we rebuild it from the config)
@context.originalServices = ${CookiePunchConfig.filterServicesArrayByWhenCondition(Configuration.setting("Sandstorm.CookiePunch.consent.services"), site)}
@context.dynamicServices = Neos.Fusion:Map {
items = ${q(site).find('[instanceof Vendor.Site:Content.CookieConsentEmbed][serviceName != ""]')}
itemRenderer = Neos.Fusion:DataStructure {
// NOTE: no `name` here — Config.fusion derives the Klaro service name
// from the map KEY (keyRenderer), not from a `name` field.
title = ${q(item).property('serviceName')}
description = ${q(item).property('serviceDescription')}
purposes = ${['externalContent']}
}
// The KEY becomes the Klaro service name. It MUST match the `data-name`
// produced by the element's blockTags/addContextualConsent above —
// i.e. hash `serviceName` exactly the same way. Using the hash as key
// also deduplicates: two embeds with the same name share one switch
// (the last one rendered wins for title/description).
keyRenderer = ${String.md5(q(item).property('serviceName'))}
}
servicesRemainingAfterWhenConditions = ${Array.concat(originalServices, dynamicServices)}
}
Then add Neos.Caching.nodeTypeTag('Vendor.Site:Content.CookieConsentEmbed') to the consent cache so a newly published embed flushes every page's consent — see Caching the consent.
4. Declare the purpose
Every purpose a service references must exist under consent.purposes (for its title/description and translations):
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: purposes: externalContent: title: External content description: Embedded third-party content that may set cookies.
Notes & caveats
- The two
String.md5(...)expressions must stay byte-identical (the element in step 2 and the consent override in step 3). If they ever drift, the markup is blocked but no service can unblock it — the content stays broken forever. - Deduplication is by name. Two embeds with the same
serviceNameproduce one switch; the last-rendered node wins fortitle/description. - Make
serviceNamerequired. An empty name hashes to a constant (md5('')), collapsing unrelated embeds into one bogus service — hence theNotEmptyValidatorand the[serviceName != ""]filter. - The
@cacheentry tag is not optional — without it, new embeds don't appear on already-cached pages. See Caching the consent.
Per-service lifecycle callbacks (onInit / onAccept / onDecline)
Each service can declare JavaScript snippets that run when Klaro initialises, when the user accepts, and when the user declines:
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: services: googleAnalytics: title: Google Analytics purposes: [analytics] # JS executed when Klaro initialises the service onInit: "console.log('GA init');" # JS executed when the user gives consent onAccept: "window.dataLayer.push({'event': 'cookie_consent_ga'});" # JS executed when the user withdraws consent onDecline: "console.log('GA declined');"
Each value is the body of a JS function. The strings are exposed via window.cookiePunchCallbacks and registered with Klaro before the main bundle loads — so they work under strict CSP without unsafe-eval. (Prior to v5 these were registered via eval(). See MIGRATIONS.md.)
A complete service config showing every supported key — including these callbacks — is in Examples/Settings.CookiePunch.FullServiceConfig.yaml.
Contextual Consent Only Mode
If you don't want to show the cookie banner or modal initially, use the global contextualConsentOnly mode introduced with version 4.4.0.
# Configuration/Settings.CookiePunch.yaml Sandstorm: CookiePunch: consent: contextualConsentOnly: true mustConsent: false
Troubleshooting
The consent modal doesn't appear
Please check
- Is
Sandstorm.CookiePunch:Consentactually included in yourNeos.Neos:Page(e.g.head.javascripts.cookiepunchConsent = Sandstorm.CookiePunch:Consent)? - Does
klaro.show()work in the DevTools console? - Does the browser console show CSP errors about an inline
<script>?
This could be the problem
- The Fusion include is missing or shadowed by another
head.javascripts.*assignment. - A strict Content Security Policy without
unsafe-inline(or a matching nonce/hash) is blocking the inline<script>produced bySandstorm.CookiePunch:Js.ConfigandSandstorm.CookiePunch:Js.Callbacks.
How to fix
Add the Fusion include from Step 1. If CSP blocks the inline script, either allow script-src 'unsafe-inline' (not recommended) or attach a nonce/hash to the consent's script tag.
A service switch is missing from the modal
Please check
- Is the service declared under
Sandstorm.CookiePunch.consent.servicesin your YAML? - Does its
when:expression (if any) evaluate truthy for the currentsite? - For editor-defined dynamic services, is the page's
Sandstorm.CookiePunch:Consent@cachetagged on the embed/text node type?
This could be the problem
- A YAML typo / mis-nesting under the wrong key (
Sandstorm:CookiePunch:vs.Sandstorm: { CookiePunch: { … } }). - A
when:Eel expression is silently false (q(site).find(...).count() == 0, missing property). - The page is showing a cached version that pre-dates the service's existence — see Caching the consent.
How to fix
Open the DevTools console and inspect window.cookiePunchConfig — every service the server emitted is listed there. If yours is missing, the issue is in the Fusion/YAML; if it's present but the switch isn't, see Caching the consent and flush the page cache.
Content stays blocked even after the user accepts
Please check
- View the rendered HTML (not the live DOM). Does the broken tag have a
data-name="…"attribute? - Does the value of that
data-namematch a servicenamefromwindow.cookiePunchConfig.services?
This could be the problem
- The matching pattern attached
block: true(no service) instead ofservice: someService, so Klaro cannot restore the markup. - For Editor-defined dynamic services, the two
String.md5(...)expressions (element side vs. consent side) have drifted — typically because the element-sideserviceNamewas edited but the consent-side query didn't refresh. - A pattern matched the URL but the wildcard
"*": falseis also present and won by mistake — see Pattern reference.
How to fix
Make data-name and the service name byte-identical. For dynamic services, ensure the consent's @cache is tagged on the embed node type so updates propagate.
The Neos backend looks broken
Please check
- Did you pass the
!node.context.inBackendargument toCookiePunch.blockTags(...)?
This could be the problem
blockTags(["iframe","script"], value)(only two arguments) blocks unconditionally — including in the Neos backend, which breaks the editor UI.
How to fix
Always pass !node.context.inBackend as the third argument:
// Resources/Private/Fusion/CookiePunch.fusion
@process.blockTags = ${CookiePunch.blockTags(["iframe","script"], value, !node.context.inBackend)}
Edits to dynamic-source nodes don't show up on already-published pages
Please check
- Does your
Sandstorm.CookiePunch:Consentoverride declare a@cacheblock with anodeTypeTagfor the source node type?
This could be the problem
- The consent is rendered inside
Neos.Neos:Page. Without explicit cache tags,q(site).find(...)reads are frozen into each page's cache entry.
How to fix
Add the missing Neos.Caching.nodeTypeTag('Vendor.Site:Document.X') to the consent cache — see Caching the consent.
Iframes work after unblocking but are the wrong size or in the wrong place
Please check
- Do you have an iframe that you blocked because it sets cookies?
- Do you have JS that manipulates this iframe?
- Is the JS not blocked while the iframe is?
- Does a reload after consenting fix the problem?
This could be the problem
- The JS runs once on page load, but the iframe is still "broken" (e.g. has the wrong size).
- The JS does some styling magic to extend the iframe to the available width.
- The JS needs to run when the iframe is in an unblocked state — otherwise its size calculation fails.
How to fix
Block the JS too — even though it doesn't set any cookies — and attach it to the same service as the iframe. The JS will then run after the iframe is unblocked.
Migration guide
For upgrade notes between major versions, see MIGRATIONS.md.
Contributing
For test, build, and translation workflows, see CONTRIBUTING.md.
