Codync
免費、開源的 1:1 替代方案,取代 Grok Bot 與 Muse。從 iPhone、Mac 或 Linux 桌面將 Claude Code、Codex、Cursor 與 40+ 程式碼代理人作為持續機器人傳訊。群組聊天、主題串…
AI 翻譯
尚未收集儲存庫資訊。
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
Consent banner, settings dialog and gated scripts/embeds for the Nera static site generator
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗A plugin for the Nera static site generator that adds a consent banner and a settings dialog, and keeps scripts and embeds inert until the visitor agrees, all configured in config/consent.yaml. A site that loads nothing it needs consent for gets only a short info notice.
📖 Documentation: nera.js.org
Requires
@nera-static/core4.13.0 or later (see Compatibility).
The texts this plugin ships are defaults, not legal advice. The plugin implements a technical floor (below). It does not make your site compliant with any law; check your texts, your services and your privacy policy yourself or with someone qualified to advise you.
<dialog>) listing every category and service with provider, purpose, cookies and privacy linktype="text/plain") and run only after consent, in document order<iframe>, <embed>, <object>, <video> or <audio> from a configured host in your Markdown becomes a two-click placeholder at build time, with no live srcrevision to ask every visitor again--nera-consent-* custom properties, with no third-party libraryThese hold whatever config/consent.yaml says. No key exists that weakens them; a key that tries is warned about and ignored.
revision bump asks every visitor again.npm install @nera-static/plugin-consent
npx nera-consent-config # copies the documented config/consent.yaml
npx nera-consent # copies the views to views/vendor/plugin-consent/
Nera detects the plugin on nera build. config/consent.yaml is optional: without it the site is in notice mode. Both commands skip a file or folder that already exists; --force overwrites it (and discards your edits).
Plugins cannot touch layouts, so wiring the views and the runtime into your layout is the one manual step:
doctype html
html(lang=meta.lang || app.lang)
head
link(rel='stylesheet' href='/_nera/consent.css')
include /vendor/plugin-consent/head
script(src='/_nera/consent.js' defer)
body
main
!= content
footer
a(href='/privacy.html' data-consent-open) Privacy settings
include /vendor/plugin-consent/banner
include /vendor/plugin-consent/settings
include /vendor/plugin-consent/notice
head emits your configured scripts, inert. Put it inside <head>.banner, settings and notice go at the end of <body>. Each renders only in its own mode, so include all three.consent.js and consent.css are copied to public/_nera/ on every build and get base_path and asset hashing like any other asset. Load the runtime with defer.consent.css sets padding-bottom on html[data-consent-reserve] body so the page can scroll clear of it. That rule overrides your own body padding-bottom for as long as either shows.Visitors must be able to change or withdraw their choice at any time, so place a link or button that reopens the settings dialog, typically in the footer. Any element with data-consent-open does it, without inline JavaScript:
a(href='/privacy.html' data-consent-open) Privacy settings
<button type="button" data-consent-open>Privacy settings</button>
On a link, the href is the fallback: without the dialog on the page (or without JavaScript) the link simply goes to your privacy page. From your own scripts, call window.neraConsent.open().
The link also receives keyboard focus once a choice hides the banner, so a keyboard user lands next to the way back instead of at the top of the page: the runtime focuses the first visible data-consent-open element outside the banner, the dialog and embed placeholders.
Every key is optional and falls back to a default, with a build warning when a value has the wrong type. Unknown keys are warned about and ignored.
# Bump to ask every visitor again (integer ≥ 1). Default: 1.
revision: 1
# Your privacy policy. Root-absolute (base_path is applied) or absolute,
# one URL for all languages or one per language. Without it the banner has
# no privacy link, and the build warns.
privacy_url:
de: /datenschutz.html
en: /en/privacy.html
# Show the info notice when there are no categories. Default: true.
# false → no consent UI at all. Ignored, with a warning, once a category exists.
notice: true
# Optional categories, in display order. None → notice mode.
categories:
- id: analytics # [a-z][a-z0-9-]*; `necessary` is reserved
label: Statistics # optional for analytics, media, marketing,
# functional (built in); else the id shows
description: Helps us … # optional
services: # at least one
- name: Matomo # required
provider: Example GmbH, Berlin # required
purpose: Counts visits without profiles. # required
privacy_url: https://example.com/privacy # optional
cookies: # optional, listed in the dialog
- name: _pk_id
duration: 13 months
purpose: Recognises returning visitors.
hosts: # optional: embed hosts gated by this service
- matomo.example.com
scripts: # optional: emitted gated by the head view
- src: https://matomo.example.com/matomo.js
async: true # optional: async | defer | module
- inline: |
var _paq = window._paq = window._paq || [];
# Optional text overrides per language (see "Texts").
texts:
fr:
banner_title: Cookies et contenus externes
label, description, purpose, privacy_url and a cookie's duration and purpose take one text or one text per language ({ de: …, en: … }).
What is checked:
necessary is dropped.name, provider and purpose, else it is dropped. A cookie needs a name.src and inline.necessary, or to change the storage key.Adding a service or a category to a site that already shows the banner does not ask returning visitors again by itself — bump revision. Adding the first category to a site in notice mode does ask again.
Paste the embed code into your Markdown as usual:
<iframe src="https://www.youtube-nocookie.com/embed/abc" title="Our video" width="560" height="315" allowfullscreen></iframe>
When the host of its src equals a configured hosts entry or is a subdomain of it (www.youtube.com matches youtube.com; notyoutube.com does not), the build replaces it with the two-click placeholder below. Everything else in the page stays byte for byte as it was. Iframes from your own site (root-absolute or relative src) are never gated.
<embed>, <object>, <video> and <audio> are gated the same way when they load from a configured host — through src, data or poster, or for video and audio through a <source> or <track> child. Their loading attributes become data-consent-src, data-consent-data, data-consent-poster and data-consent-srcset until consent, and the same placeholder follows them. An element mixing services of two categories fails the build: split it into one element per category. Images (<img>, <picture>) are not gated. Details: Gated media.
A gated iframe loses a srcdoc attribute, which would otherwise render before consent. An iframe or media element the build cannot reach, such as one inside a <select>, keeps its live src; the build warns with the page and the host, so move it out or use the mixin below.
To place an embed in a Pug view instead, use the mixin from the published embed view. It renders exactly the same placeholder:
include /vendor/plugin-consent/embed
+consent-embed({
category: 'media',
service: 'YouTube',
src: 'https://www.youtube-nocookie.com/embed/abc',
attributes: { title: 'Our video', width: 560, height: 315, allowfullscreen: true },
})
Pass the iframe's attributes in attributes, not as Pug attributes. category and service must name a configured service; otherwise the build fails rather than render a live iframe.
"Load YouTube" in a placeholder loads that one embed for the current page view and stores nothing. Lasting consent for the category comes from the banner or the settings dialog, which the placeholder links to.
Anything you can configure under scripts you can also write by hand, anywhere in a page or view, in the same form (see Markup contract). The runtime treats both alike.
Each text resolves per page language (meta.lang || app.lang), first hit wins:
config/consent.yaml → texts.<lang>.<key>app.translations[<lang>]['consent.<key>'] (your config/app.yaml translations)<lang> text (de, en)en text| key | English default |
|---|---|
banner_title | Cookies and external content |
banner_text | This site can load content and services from third parties. … |
accept_all / reject_all / settings | Accept all / Reject all / Settings |
dialog_title / dialog_text | Privacy settings / Choose which categories you agree to. … |
save / close | Save selection / Close |
necessary_label / necessary_description / always_active | Strictly necessary / Required for the site to work, … / Always active |
service_provider / service_purpose / service_privacy | Provider / Purpose / Privacy policy of the provider |
cookies_heading / cookie_name / cookie_duration / cookie_purpose | Cookies / Name / Duration / Purpose |
notice_text / notice_dismiss | This site uses only technically necessary cookies. / Got it |
placeholder_text | This content is provided by {service}. Loading it connects to {provider}. |
placeholder_load / placeholder_settings | Load {service} / Privacy settings |
privacy_link | Privacy policy |
{service} and {provider} are filled in per embed. An empty override is warned about and ignored, so no label can be blanked out. Categories with the ids analytics, media, marketing and functional have built-in German and English labels and descriptions; a category's own label and description always win.
A French site, for example:
texts:
fr:
banner_title: Cookies et contenus externes
accept_all: Tout accepter
reject_all: Tout refuser
settings: Paramètres
The attributes below and the BEM classes are public contract (@nera-static/validate 1.6.0 and later read them: privacy-third-party skips gated markup, and privacy-consent-leak reports an element marked data-consent that still loads); renaming any of them is a major release. The runtime binds to the data-consent* attributes only, never to classes: restyle or rename classes in your published views freely, but keep every data-consent* attribute.
<script type="text/plain" data-consent="analytics" data-src="https://matomo.example.com/matomo.js" async></script>
<script type="text/plain" data-consent="analytics">var _paq = window._paq || [];</script>
<script type="text/plain" data-consent="analytics" data-type="module" data-src="/js/stats.js"></script>
type="text/plain" makes the element inert until the runtime activates it.data-consent="<category id>" — required; the category whose consent activates it.data-src — the script URL (root-absolute values get base_path); absent for an inline script.data-type="module" — activated as type="module".async, defer, integrity, crossorigin, nonce, id, data-* …) is copied to the activated script unchanged.<div class="nera-consent-embed" data-consent-embed data-consent="media">
<iframe data-consent="media" data-consent-src="https://www.youtube-nocookie.com/embed/abc" title="…" width="560" height="315" allow="…" hidden></iframe>
<div class="nera-consent-embed__placeholder">
<p class="nera-consent-embed__text">… connects to <strong>Google Ireland Limited</strong> …</p>
<button class="nera-consent-embed__load" type="button" data-consent-load>…</button>
<a class="nera-consent-embed__privacy" href="https://policies.google.com/privacy" rel="noopener">…</a>
<button class="nera-consent-embed__settings" type="button" data-consent-open>…</button>
</div>
</div>
The build writes it on one line; it is shown indented here.
<div class="nera-consent-embed" data-consent-embed data-consent="media">
<video data-consent="media" controls data-consent-poster="https://cdn.example.com/p.jpg" hidden>
<source data-consent-src="https://cdn.example.com/v.webm" type="video/webm">
<track data-consent-src="https://cdn.example.com/c.vtt" kind="captions">
…fallback content…
</video>
<div class="nera-consent-embed__placeholder">…the same controls as for an iframe…</div>
</div>
<iframe>, the build gates <embed>, <object>, <video> and <audio>. An element is gated when any URL it loads through a loading attribute has a configured host, matched exactly as for iframes: embed[src], object[data], video[src], video[poster], audio[src], and for <video> / <audio> also their child <source src>, <source srcset> (every candidate URL) and <track src>.<source> / <track> children is renamed in place to data-consent-<name> (data-consent-src, data-consent-data, data-consent-poster, data-consent-srcset), own-host ones included, so nothing of the element loads before consent. The element gets data-consent="<category>" and hidden; children get neither. Every other attribute stays, in source order.div.nera-consent-embed wrapper and placeholder as for an iframe; the placeholder follows the element. The element's fallback content is kept as is, except that gated elements inside it are gated in turn, each with its own placeholder.data-consent, so the build fails with the page, the tag and the categories. Split such an element into one per category.<img>, <picture> and its <source srcset>, CSS url(), <link>. Own-host and relative URLs never gate an element. The +consent-embed mixin renders iframes only.hidden, sets every data-consent-<name> back as <name> (on a media element its <source> / <track> children first) and calls load() on <video> / <audio>. A placeholder's load button activates only the element directly in its wrapper, never a gated element nested in that element's fallback.| attribute | on | meaning |
|---|---|---|
data-consent="<id>" | gated script, gated iframe or media element, embed wrapper | category that activates it |
data-src | gated script | script URL |
data-type="module" | gated script | activate as a module |
data-consent-src | gated iframe, <embed>, <video>, <audio>, their <source> / <track> | the src |
data-consent-data | gated <object> | the data URL |
data-consent-poster | gated <video> | the poster URL |
data-consent-srcset | <source> in a gated <video> / <audio> | the srcset |
data-consent-embed | placeholder wrapper | one two-click embed |
data-consent-load | button in a placeholder | load this one embed now |
data-consent-open | any element | open the settings dialog |
data-consent-banner | banner root | carries data-consent-revision and data-consent-categories (space-separated ids) |
data-consent-notice | notice root | carries data-consent-revision |
data-consent-dialog | the settings <dialog> | |
data-consent-accept-all / data-consent-reject-all | buttons in banner and dialog | grant all / only necessary |
data-consent-save | button in the dialog | store exactly the ticked categories |
data-consent-category="<id>" | checkbox in the dialog |
nera-consent (the banner; nera-consent--notice for the notice), nera-consent-dialog (the settings dialog) and nera-consent-embed (the placeholder). All styling uses --nera-consent-* custom properties (see Theming). The elements, as the published views render them:
section.nera-consent (views/banner.pug, data-consent-banner, aria-labelledby → the title, rendered hidden): h2.__title#nera-consent-title, p.__text (banner_text, then a.__link → privacy page, left out without privacy_url), div.__actions with exactly three button.__button in fixed order: Accept all (data-consent-accept-all), Reject all (data-consent-reject-all), Settings (data-consent-open). No modifier on any of them.section.nera-consent.nera-consent--notice (views/notice.pug, data-consent-notice, aria-labelledby → its text, rendered hidden): p.__text#nera-consent-notice-text (notice_text + a.__link), div.__actions with one button.__button (data-consent-dismiss).dialog.nera-consent-dialog (views/settings.pug, data-consent-dialog, aria-labelledby → its title): one form.__form[method=dialog] holding button.__close[type=submit] (× with aria-label/title = close), h2.__title#nera-consent-dialog-title, p.__text (+ a.__link), then one div.__category per category: first necessary (its checkbox checked disabled, no data-consent-category, span.__status "(Always active)"), then each configured one: label.__label wrapping input.__checkbox[type=checkbox][data-consent-category] (never checked in the markup) and the label text, p.__description when there is one, and per service div.__service with h3.__service-name, dl.__details (provider, purpose), p > a.__link (the service's privacy link, when set) and table.__table (caption cookies_heading, columns name/duration/purpose; only when the service lists cookies). Last div.__actions with three button.__button: Accept all, Reject all, Save selection (data-consent-save).views/head.pug emits no classes: only the gated <script> elements of Gated scripts, inline code raw with </script and <!-- escaped (<\/script, <\!--) so it cannot end the element.
The runtime exposes window.neraConsent:
| member | returns | |
|---|---|---|
open() | undefined | opens the settings dialog, stored choice ticked |
get() | { categories, revision, timestamp, mode } or null | a copy of the current valid choice |
has(id) | boolean | true for necessary; else whether id is granted |
After every stored choice it dispatches nera-consent:change on document, with the choice as event.detail:
document.addEventListener('nera-consent:change', (event) => {
if (event.detail.categories.includes('analytics')) {
// start something that is not a gated script
}
})
The choice is stored in localStorage under nera-consent, as JSON:
{ "categories": ["analytics"], "revision": 1, "timestamp": "2026-10-16T10:00:00.000Z", "mode": "banner" }
timestamp records when the choice was made. When storage is blocked (a private window, say), the choice holds for the current page view only and the banner shows again on the next one.
app.consentgetAppData adds app.consent for your own views and your privacy page, keeping every other app key:
app.consent = {
mode: 'banner', // 'banner' with ≥ 1 category, else 'notice' ('none' with `notice: false`)
revision: 1,
storageKey: 'nera-consent',
languages: ['de', 'en'], // app.lang, every meta.lang, every `texts` language, always `en`
locales: {
de: {
privacyUrl: '/datenschutz.html', // base_path applied; null when unset
texts: { banner_title: '…', /* every key under "Texts and languages" */ },
categories: [{
id: 'analytics', label: '…', description: '…',
services: [{
name, provider, purpose, privacyUrl,
cookies: [{ name, duration, purpose }],
}],
}],
},
en: { /* same shape */ },
},
scripts: [ // language-independent, document order
{ category: 'analytics', service: 'Matomo', src: 'https://…', async: true, defer: false, module: false },
{ category: 'analytics', service: 'Matomo', inline: 'var _paq = …', async: false, defer: false, module: false },
],
hosts: [{ category: 'media', service: 'YouTube', host: 'youtube.com' }],
}
Renaming or restructuring any of these keys is a major release. To list your services on the privacy page:
- const consentLocale = app.consent.locales[meta.lang || app.lang] || app.consent.locales.en
each category in consentLocale.categories
h3= category.label
each service in category.services
p #{service.name} (#{service.provider}): #{service.purpose}
Every color and size comes from a --nera-consent-* custom property with the default as its fallback, so you can set them anywhere, :root included, regardless of stylesheet order:
:root {
--nera-consent-bg: var(--surface);
--nera-consent-fg: var(--ink);
--nera-consent-button-bg: var(--accent);
--nera-consent-button-fg: var(--on-accent);
}
| Property | Default | Used for |
|---|---|---|
--nera-consent-font | inherit | font of banner, dialog and placeholder |
--nera-consent-font-size | 1rem | font size |
--nera-consent-bg | #ffffff | banner and dialog background |
--nera-consent-fg | #1f2937 | banner and dialog text |
--nera-consent-border | #6b7280 | borders of banner, dialog, categories and placeholder |
--nera-consent-radius | 0.5rem | corners of banner, dialog, categories and placeholder |
--nera-consent-shadow | 0 0.5rem 2rem rgb(0 0 0 / 0.2) | banner shadow |
--nera-consent-max-width | 40rem | banner width |
--nera-consent-dialog-width | 40rem | dialog width |
--nera-consent-z-index | 1000 | banner stacking |
--nera-consent-backdrop | rgb(0 0 0 / 0.5) | dialog backdrop |
--nera-consent-button-bg | #1f2937 | all buttons (and checkboxes) |
--nera-consent-button-fg | #ffffff | button text |
--nera-consent-button-radius | 0.375rem | button corners |
--nera-consent-link | #1d4ed8 | links in banner and dialog |
--nera-consent-focus | #1d4ed8 | focus ring |
--nera-consent-placeholder-bg | #f3f4f6 | placeholder background |
--nera-consent-placeholder-fg |
Every default text pair reaches a contrast of at least 4.5:1, focus ring and borders at least 3:1. Keep that when you change them. Accept all, Reject all and Settings share one set of button properties on purpose: there is no way to style one of them apart. For dark mode, redefine the properties in your own media query.
+consent-embed mixin there.<object> URLs given only through <param name="movie"> (or src, url, code) are not gated; give the <object> a data attribute.<video>, <audio> or <embed> written inline in a Markdown paragraph ends up inside <p>, and the browser splits that paragraph around the placeholder (extra spacing only). Put its opening tag alone on a line, after a blank line, so Markdown keeps it as a block.@nera-static/core 4.13.0 or later (the getAssets hook, which copies the runtime into public/). On an older core the build stops with a message naming the required version. With notice: false and no categories the check is skipped.@nera-static/validate: nera build --check reports an embed or script that is still ungated (privacy-third-party); gated ones are not reported.npx vitest run
npm run lint
npm test runs Vitest in watch mode. The runtime tests run in happy-dom; the fixture tests build test/fixture/ with the real @nera-static/core in a temp directory and check the output with @nera-static/validate.
MIT
| one category |
data-consent-dismiss | "Got it" in the notice | store the notice as seen |
data-consent-state="active" | gated script / iframe / media element, set by the runtime | already activated (never twice) |
div.nera-consent-embed: the placeholder under Gated iframes (views/embed.pug renders it too).#1f2937 |
| placeholder text |
--nera-consent-placeholder-link | #1d4ed8 | placeholder link |
--nera-consent-banner-height | 0px | set by the runtime while the banner shows, so the page can scroll clear of it — do not set it yourself |