@ampless/plugin-cookie-consent 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ampless contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ja.md ADDED
@@ -0,0 +1,126 @@
1
+ > English: [README.md](./README.md)
2
+ >
3
+
4
+ # @ampless/plugin-cookie-consent
5
+
6
+ [ampless](https://github.com/heavymoons/ampless) 向け GDPR/ePrivacy 対応 Cookie 同意バナープラグイン。
7
+
8
+ > **プレリリース / アルファ版。** v1.0 まではマイナーバージョンでも破壊的変更が入る可能性があります。
9
+
10
+ 公開ページの `<head>` に `window.amplessConsent` Consent Convention API([プラグインアーキテクチャ doc](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.ja.md) §Consent Convention)をインストールし、React ツリー外に設定可能な同意バナーを `<body>` に追加します。analytics / トラッキング系プラグインはこの API を利用して、訪問者が同意するまで自身を無効化します。
11
+
12
+ AWS のデータ権限は不要です。すべて公開 Next.js プロセスの描画時に動作します。`trust_level` は `untrusted`。
13
+
14
+ ## インストール
15
+
16
+ ```bash
17
+ npm install @ampless/plugin-cookie-consent@alpha
18
+ ```
19
+
20
+ ## 設定
21
+
22
+ `cms.config.ts` に記述します:
23
+
24
+ ```ts
25
+ import { defineConfig } from 'ampless'
26
+ import cookieConsentPlugin from '@ampless/plugin-cookie-consent'
27
+
28
+ export default defineConfig({
29
+ // ...
30
+ plugins: [
31
+ // cookie-consent を最初に登録し、後続の analytics プラグインが
32
+ // window.amplessConsent を参照できるようにする。
33
+ cookieConsentPlugin(),
34
+ // analyticsGa4Plugin({ ... }), // PR D 以降
35
+ ],
36
+ })
37
+ ```
38
+
39
+ | オプション | デフォルト | 備考 |
40
+ |---|---|---|
41
+ | `instanceId` | `'cookie-consent'` | スクリプト要素 id の namespace。同じサイトに複数登録する場合のみ変更する。 |
42
+
43
+ ## 設定項目(管理画面)
44
+
45
+ `/admin/plugins → Cookie Consent` から設定できます:
46
+
47
+ | キー | 型 | デフォルト | 備考 |
48
+ |---|---|---|---|
49
+ | `bannerText` | textarea | `'このサイトは…'` | バナー上部に表示するメッセージ。 |
50
+ | `acceptLabel` | text | `'Accept all'` | 「すべて同意」ボタンのラベル。 |
51
+ | `rejectLabel` | text | `'Reject non-essential'` | 「拒否」ボタンのラベル。 |
52
+ | `position` | select | `'bottom'` | `'bottom'` / `'top'` / `'modal'`。 |
53
+ | `categories` | repeatable | `[]` | 同意カテゴリのリスト。 |
54
+
55
+ ### 同意カテゴリ
56
+
57
+ `categories` repeatable フィールドの各カテゴリは以下のサブフィールドを持ちます:
58
+
59
+ | サブフィールド | 型 | 必須 | 備考 |
60
+ |---|---|---|---|
61
+ | `id` | text | yes | 機械可読な識別子。例: `'analytics'`。パターン: `^[a-z][a-z0-9_-]*$`。リスト内で一意である必要があり、重複は first-wins で除去されます。 |
62
+ | `label` | text | yes | バナーのチェックボックス横に表示する名称。 |
63
+ | `description` | textarea | no | ラベルの下に表示する短い説明。 |
64
+ | `defaultEnabled` | boolean | no | **UI ヒントのみ。** 未決定の訪問者向けのチェックボックス初期状態。localStorage には事前同意として保存**されません** — 暗黙的同意は GDPR/ePrivacy に反するため。一度 accept/reject を行えば、その明示的な選択が以後この hint を上書きします。 |
65
+ | `essential` | boolean | no | 常時 ON(トグル不可)。`defaultEnabled` より優先される。 |
66
+
67
+ 管理画面での設定例 — 2 カテゴリを追加:
68
+
69
+ ```
70
+ id: analytics label: アクセス解析
71
+ id: marketing label: マーケティング・パーソナライズ
72
+ ```
73
+
74
+ ### バナーが再表示される条件
75
+
76
+ 非 essential カテゴリの **すべて** に対してユーザが決定(accept または reject)を行ったら、以後バナーは表示されません — [`window.amplessConsent.isSet`](#api) で判定します。一度 reject されたカテゴリは reload 時に再プロンプトされません。決定を取り消したい場合は localStorage の `ampless:consent` キーを削除するか、サイト側で「環境設定の見直し」リンクから直接 `window.amplessConsent.set(cat, …)` を呼んでください。
77
+
78
+ ## Consent Convention {#api}
79
+
80
+ このプラグインは ampless Consent Convention を実装します。インストール後、すべてのページで次の API が使えます:
81
+
82
+ ```js
83
+ window.amplessConsent.has('analytics') // → true なら同意済み
84
+ window.amplessConsent.isSet('analytics') // → true ならユーザが決定済み(accept または reject)
85
+ window.amplessConsent.on('analytics', function() { /* 同意後に一度だけ発火 */ }) // unsubscribe 関数を返す
86
+ window.amplessConsent.set('analytics', true) // バナー UI が呼ぶ
87
+ ```
88
+
89
+ analytics の gate には `has`、バナーが「再プロンプトすべきか」の判定には `isSet` を使う — Reject 後にバナーが永遠に出続けないのはこれが理由です。
90
+
91
+ `window` 上で発火する標準イベント:
92
+
93
+ - `ampless:consent-ready` — API インストールと localStorage restore の直後に 1 度だけ発火。
94
+ - `ampless:consent-changed` — `set()` のたびに発火。`detail: { category, granted }`。
95
+
96
+ 同意状態は `localStorage` のキー `'ampless:consent'` に `Record<string, boolean>` の JSON として保存されます。
97
+
98
+ 詳細な API 仕様と analytics 側の consume パターンは [`docs/architecture/08-plugin-architecture.ja.md` — Consent Convention](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.ja.md#consent-convention) を参照してください。
99
+
100
+ ## analytics プラグインとの組み合わせ(PR D 以降)
101
+
102
+ GA4 / GTM / Plausible プラグインに `consentCategory` サポートが追加された後(Phase 3b PR D)、次のように組み合わせられます:
103
+
104
+ ```ts
105
+ plugins: [
106
+ cookieConsentPlugin(),
107
+ analyticsGa4Plugin({ measurementId: 'G-XXXXXXXX', consentCategory: 'analytics' }),
108
+ gtmPlugin({ containerId: 'GTM-XXXXXXX', consentCategory: 'analytics' }),
109
+ ]
110
+ ```
111
+
112
+ `consentCategory` を設定した analytics プラグインは、訪問者が該当カテゴリに同意するまで発火しません。`window.amplessConsent` がインストールされていない場合(`cookieConsentPlugin` が `cms.config.ts` に未登録の場合)、トラッキングは**永久に発火しません** — これは意図した fail-closed 設計です。
113
+
114
+ > **注意:** GA4 / GTM / Plausible の `consentCategory` 対応は Phase 3b PR D で実装予定(未リリース)です。
115
+
116
+ ## トラストレベル
117
+
118
+ `untrusted`。`@ampless/runtime` が検証・描画する inline script descriptor を返すだけです。DynamoDB、S3、Lambda プロセッサーには一切触れません。
119
+
120
+ ## v1 では対応しないこと
121
+
122
+ - **テーマ統合** — バナーのスタイルはライトテーマ固定です。CSS 変数やテーマ API を使ったカスタマイズは、対応する capability surface が整ってから検討します。
123
+ - **GPC / DNT シグナルの自動処理** — Global Privacy Control や Do Not Track シグナルは自動的に反映されません。デフォルトの同意状態は運用者が設定してください。
124
+ - **管轄区域別のデフォルト** — 訪問者の地域(EU か否か)を自動判定してオプトイン/アウトのデフォルトを切り替える機能はありません。
125
+ - **サブカテゴリのネスト** — 各カテゴリはフラットな boolean です。ネストされたサブカテゴリは deferred です。
126
+ - **アイテムの並び替え** — 管理画面の `categories` repeatable は追加/削除のみで、v1 ではドラッグによる並び替えはサポートしません。
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ > 日本語版: [README.ja.md](./README.ja.md)
2
+ >
3
+
4
+ # @ampless/plugin-cookie-consent
5
+
6
+ GDPR/ePrivacy cookie consent banner plugin for [ampless](https://github.com/heavymoons/ampless).
7
+
8
+ > **Pre-release / alpha.** Breaking changes possible in any minor version until v1.0.
9
+
10
+ Installs the `window.amplessConsent` Consent Convention API (§6 of the [plugin architecture docs](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md)) in every public page's `<head>`, then appends a configurable consent banner to `<body>` outside the React tree. Analytics and tracking plugins use the API to gate themselves until the visitor grants consent.
11
+
12
+ No AWS data permissions are required — everything runs at request time inside the public Next.js process. The plugin's `trust_level` is `untrusted`.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @ampless/plugin-cookie-consent@alpha
18
+ ```
19
+
20
+ ## Configure
21
+
22
+ In `cms.config.ts`:
23
+
24
+ ```ts
25
+ import { defineConfig } from 'ampless'
26
+ import cookieConsentPlugin from '@ampless/plugin-cookie-consent'
27
+
28
+ export default defineConfig({
29
+ // ...
30
+ plugins: [
31
+ // List cookie-consent FIRST so its window.amplessConsent API is
32
+ // available when subsequent analytics plugins initialise.
33
+ cookieConsentPlugin(),
34
+ // analyticsGa4Plugin({ ... }), // PR D — coming soon
35
+ ],
36
+ })
37
+ ```
38
+
39
+ | Option | Default | Notes |
40
+ |---|---|---|
41
+ | `instanceId` | `'cookie-consent'` | Namespace used for script element ids. Change only if registering the plugin twice. |
42
+
43
+ ## Settings (admin UI)
44
+
45
+ Configure from `/admin/plugins → Cookie Consent`:
46
+
47
+ | Key | Type | Default | Notes |
48
+ |---|---|---|---|
49
+ | `bannerText` | textarea | `'This site uses cookies…'` | Message shown at the top of the banner. |
50
+ | `acceptLabel` | text | `'Accept all'` | Label for the "accept all" button. |
51
+ | `rejectLabel` | text | `'Reject non-essential'` | Label for the "reject" button. |
52
+ | `position` | select | `'bottom'` | `'bottom'` / `'top'` / `'modal'`. |
53
+ | `categories` | repeatable | `[]` | List of consent categories. |
54
+
55
+ ### Consent categories
56
+
57
+ Each category in the `categories` repeatable field has the following sub-fields:
58
+
59
+ | Sub-field | Type | Required | Notes |
60
+ |---|---|---|---|
61
+ | `id` | text | yes | Machine-readable, e.g. `'analytics'`. Pattern: `^[a-z][a-z0-9_-]*$`. Must be unique within the list — duplicates are silently dropped, first occurrence wins. |
62
+ | `label` | text | yes | Shown next to the checkbox in the banner. |
63
+ | `description` | textarea | no | Short description shown below the label. |
64
+ | `defaultEnabled` | boolean | no | **UI hint only.** Pre-checks the toggle for visitors who have not yet decided. Does **not** pre-grant consent in localStorage — implicit consent would violate GDPR/ePrivacy. Once the visitor accepts/rejects, their explicit choice replaces this hint on every subsequent visit. |
65
+ | `essential` | boolean | no | Always granted; not shown as a toggle. Overrides `defaultEnabled`. |
66
+
67
+ Example configuration via the admin UI — add two categories:
68
+
69
+ ```
70
+ id: analytics label: Analytics
71
+ id: marketing label: Marketing & personalisation
72
+ ```
73
+
74
+ ### When the banner re-appears
75
+
76
+ The banner skips itself when every non-essential category has been *decided* (accepted **or** rejected) by the user — checked via [`window.amplessConsent.isSet`](#api). A previously rejected category is not re-prompted on reload; visitors clear their decisions by deleting the `ampless:consent` key from localStorage (or your site can expose a "review preferences" link that calls `window.amplessConsent.set(cat, …)` directly).
77
+
78
+ ## Consent Convention {#api}
79
+
80
+ This plugin implements the ampless Consent Convention. Once installed, every page exposes:
81
+
82
+ ```js
83
+ window.amplessConsent.has('analytics') // → true only if granted
84
+ window.amplessConsent.isSet('analytics') // → true if user made a choice (accept OR reject)
85
+ window.amplessConsent.on('analytics', function() { /* granted */ }) // returns unsubscribe fn
86
+ window.amplessConsent.set('analytics', true) // called by banner UI
87
+ ```
88
+
89
+ Use `has` to gate analytics; use `isSet` to detect "has the visitor made a decision yet?" (which is how the banner avoids re-prompting after a Reject).
90
+
91
+ Standard events fired on `window`:
92
+
93
+ - `ampless:consent-ready` — fired once after the API is installed and localStorage is restored.
94
+ - `ampless:consent-changed` — fired on each `set()` call, `detail: { category, granted }`.
95
+
96
+ Consent state is persisted in `localStorage` under the key `'ampless:consent'` as a `Record<string, boolean>` JSON object.
97
+
98
+ For the full API specification and the analytics consume pattern see [`docs/architecture/08-plugin-architecture.md` — Consent Convention](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#consent-convention).
99
+
100
+ ## Combining with analytics plugins (coming in PR D)
101
+
102
+ Once GA4, GTM, and Plausible plugins add `consentCategory` support (Phase 3b PR D), you can gate them like this:
103
+
104
+ ```ts
105
+ plugins: [
106
+ cookieConsentPlugin(),
107
+ analyticsGa4Plugin({ measurementId: 'G-XXXXXXXX', consentCategory: 'analytics' }),
108
+ gtmPlugin({ containerId: 'GTM-XXXXXXX', consentCategory: 'analytics' }),
109
+ ]
110
+ ```
111
+
112
+ With `consentCategory` set, the analytics plugin will not fire until the visitor grants consent for that category. If `window.amplessConsent` is not installed (i.e. `cookieConsentPlugin` is missing from `cms.config.ts`), tracking will **never fire** — this is the intended fail-closed design.
113
+
114
+ > **Note:** `consentCategory` support in GA4, GTM, and Plausible is implemented in Phase 3b PR D (not yet released).
115
+
116
+ ## Trust level
117
+
118
+ `untrusted`. The plugin only emits inline script descriptors validated and rendered by `@ampless/runtime`. It does not access DynamoDB, S3, or any Lambda processor.
119
+
120
+ ## What it does not do (v1)
121
+
122
+ - **Theme integration** — The banner style is light-theme fixed. Custom theming via CSS variables or a theme API is deferred until that capability surface exists.
123
+ - **GPC / DNT signal handling** — Global Privacy Control and Do Not Track signals are not automatically honoured; the operator must set default consent states accordingly.
124
+ - **Jurisdiction-aware defaults** — No automatic detection of visitor region (EU vs. non-EU) to adjust opt-in/opt-out defaults.
125
+ - **Granular sub-categories** — Each category is a flat boolean. Nested sub-categories are deferred.
126
+ - **Item reordering** — The admin `categories` repeatable supports add/remove but not drag-to-reorder in v1.
@@ -0,0 +1,43 @@
1
+ import { AmplessPlugin } from 'ampless';
2
+
3
+ /** A single consent category configured by the site operator. */
4
+ interface ConsentCategory {
5
+ /** Machine-readable identifier, e.g. `'analytics'`. Pattern: `^[a-z][a-z0-9_-]*$`. */
6
+ id: string;
7
+ /** Human-readable label shown in the banner UI. */
8
+ label: string;
9
+ /** Optional description shown below the label in the banner UI. */
10
+ description?: string;
11
+ /** Whether the category is enabled by default before the visitor makes a choice. */
12
+ defaultEnabled?: boolean;
13
+ /**
14
+ * Essential categories are always granted and cannot be toggled by the
15
+ * visitor. The `publicHead` install script enforces `state[id] = true`
16
+ * for every essential category on every page load, overriding any stored
17
+ * value.
18
+ */
19
+ essential?: boolean;
20
+ }
21
+ interface CookieConsentOptions {
22
+ /**
23
+ * Optional namespace for this instance. Defaults to `'cookie-consent'`.
24
+ * Set distinct values if registering the plugin more than once on the
25
+ * same site.
26
+ */
27
+ instanceId?: string;
28
+ }
29
+ /**
30
+ * Factory for the cookie consent plugin. Returns a plugin manifest that:
31
+ *
32
+ * - `publicHead`: installs `window.amplessConsent` API before analytics
33
+ * plugins run so that `has()` / `on()` are available synchronously.
34
+ *
35
+ * - `publicBodyEnd`: appends a cookie banner to `document.body` after the
36
+ * page is interactive. The banner is rendered outside the React tree to
37
+ * avoid hydration conflicts. If `categories` is empty, or if all
38
+ * non-essential categories are already granted (returning visitor), the
39
+ * banner is not shown.
40
+ */
41
+ declare function cookieConsentPlugin(options?: CookieConsentOptions): AmplessPlugin;
42
+
43
+ export { type ConsentCategory, type CookieConsentOptions, cookieConsentPlugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,412 @@
1
+ // src/index.ts
2
+ import { definePlugin } from "ampless";
3
+ function escapeJsString(s) {
4
+ let out = "";
5
+ for (let i = 0; i < s.length; i++) {
6
+ const ch = s[i];
7
+ if (ch === "\\") out += "\\\\";
8
+ else if (ch === "'") out += "\\'";
9
+ else if (ch === "\r") out += "\\r";
10
+ else if (ch === "\n") out += "\\n";
11
+ else if (ch === "\u2028") out += "\\u2028";
12
+ else if (ch === "\u2029") out += "\\u2029";
13
+ else out += ch;
14
+ }
15
+ return out;
16
+ }
17
+ function dedupCategoriesById(cats) {
18
+ const seen = /* @__PURE__ */ new Set();
19
+ const out = [];
20
+ for (const c of cats) {
21
+ if (typeof c?.id !== "string" || c.id === "") continue;
22
+ if (seen.has(c.id)) continue;
23
+ seen.add(c.id);
24
+ out.push(c);
25
+ }
26
+ return out;
27
+ }
28
+ function cookieConsentPlugin(options = {}) {
29
+ const { instanceId = "cookie-consent" } = options;
30
+ return definePlugin({
31
+ name: "cookie-consent",
32
+ packageName: "@ampless/plugin-cookie-consent",
33
+ instanceId,
34
+ displayName: { en: "Cookie Consent", ja: "Cookie \u540C\u610F" },
35
+ apiVersion: 1,
36
+ trust_level: "untrusted",
37
+ capabilities: ["publicHead", "publicBody", "adminSettings"],
38
+ settings: {
39
+ public: [
40
+ {
41
+ type: "textarea",
42
+ key: "bannerText",
43
+ maxLength: 1e3,
44
+ label: {
45
+ en: "Banner text",
46
+ ja: "\u30D0\u30CA\u30FC\u306E\u30C6\u30AD\u30B9\u30C8"
47
+ },
48
+ description: {
49
+ en: "Message displayed in the cookie consent banner.",
50
+ ja: "Cookie \u540C\u610F\u30D0\u30CA\u30FC\u306B\u8868\u793A\u3059\u308B\u30E1\u30C3\u30BB\u30FC\u30B8\u3002"
51
+ },
52
+ default: "This site uses cookies to enhance your browsing experience. You can manage your preferences below."
53
+ },
54
+ {
55
+ type: "text",
56
+ key: "acceptLabel",
57
+ maxLength: 50,
58
+ label: {
59
+ en: "Accept button label",
60
+ ja: "\u300C\u3059\u3079\u3066\u540C\u610F\u300D\u30DC\u30BF\u30F3\u306E\u30E9\u30D9\u30EB"
61
+ },
62
+ default: "Accept all"
63
+ },
64
+ {
65
+ type: "text",
66
+ key: "rejectLabel",
67
+ maxLength: 50,
68
+ label: {
69
+ en: "Reject button label",
70
+ ja: "\u300C\u62D2\u5426\u300D\u30DC\u30BF\u30F3\u306E\u30E9\u30D9\u30EB"
71
+ },
72
+ default: "Reject non-essential"
73
+ },
74
+ {
75
+ type: "select",
76
+ key: "position",
77
+ label: {
78
+ en: "Banner position",
79
+ ja: "\u30D0\u30CA\u30FC\u306E\u8868\u793A\u4F4D\u7F6E"
80
+ },
81
+ options: [
82
+ {
83
+ value: "bottom",
84
+ label: { en: "Bottom bar", ja: "\u753B\u9762\u4E0B\u90E8\u30D0\u30FC" }
85
+ },
86
+ {
87
+ value: "top",
88
+ label: { en: "Top bar", ja: "\u753B\u9762\u4E0A\u90E8\u30D0\u30FC" }
89
+ },
90
+ {
91
+ value: "modal",
92
+ label: { en: "Modal overlay", ja: "\u30E2\u30FC\u30C0\u30EB\u30AA\u30FC\u30D0\u30FC\u30EC\u30A4" }
93
+ }
94
+ ],
95
+ default: "bottom"
96
+ },
97
+ {
98
+ type: "repeatable",
99
+ key: "categories",
100
+ label: {
101
+ en: "Consent categories",
102
+ ja: "\u540C\u610F\u30AB\u30C6\u30B4\u30EA"
103
+ },
104
+ description: {
105
+ en: 'Define the consent categories shown in the banner. Each category must have a unique machine-readable id (e.g. "analytics") and a human-readable label.',
106
+ ja: '\u30D0\u30CA\u30FC\u306B\u8868\u793A\u3059\u308B\u540C\u610F\u30AB\u30C6\u30B4\u30EA\u3092\u5B9A\u7FA9\u3057\u307E\u3059\u3002\u5404\u30AB\u30C6\u30B4\u30EA\u306B\u4E00\u610F\u306E\u8B58\u5225\u5B50\uFF08\u4F8B: "analytics"\uFF09\u3068\u8868\u793A\u540D\u304C\u5FC5\u8981\u3067\u3059\u3002'
107
+ },
108
+ maxItems: 20,
109
+ itemLabelKey: "id",
110
+ addLabel: {
111
+ en: "+ Add category",
112
+ ja: "+ \u30AB\u30C6\u30B4\u30EA\u3092\u8FFD\u52A0"
113
+ },
114
+ fields: [
115
+ {
116
+ type: "text",
117
+ key: "id",
118
+ label: { en: "ID", ja: "ID" },
119
+ required: true,
120
+ pattern: "^[a-z][a-z0-9_-]*$",
121
+ maxLength: 32,
122
+ placeholder: "analytics"
123
+ },
124
+ {
125
+ type: "text",
126
+ key: "label",
127
+ label: { en: "Label", ja: "\u30E9\u30D9\u30EB" },
128
+ required: true,
129
+ maxLength: 100
130
+ },
131
+ {
132
+ type: "textarea",
133
+ key: "description",
134
+ label: { en: "Description", ja: "\u8AAC\u660E" },
135
+ maxLength: 500
136
+ },
137
+ {
138
+ type: "boolean",
139
+ key: "defaultEnabled",
140
+ label: {
141
+ en: "Enabled by default",
142
+ ja: "\u30C7\u30D5\u30A9\u30EB\u30C8\u3067\u6709\u52B9"
143
+ }
144
+ },
145
+ {
146
+ type: "boolean",
147
+ key: "essential",
148
+ label: {
149
+ en: "Essential (always on, cannot be disabled)",
150
+ ja: "\u5FC5\u9808\uFF08\u5E38\u6642 ON\u3001\u7121\u52B9\u5316\u4E0D\u53EF\uFF09"
151
+ }
152
+ }
153
+ ]
154
+ }
155
+ ]
156
+ },
157
+ publicHead(ctx) {
158
+ const rawCategories = ctx.setting("categories") ?? [];
159
+ const categories = dedupCategoriesById(
160
+ Array.isArray(rawCategories) ? rawCategories : []
161
+ );
162
+ const categoriesJson = JSON.stringify(categories);
163
+ return [
164
+ {
165
+ type: "inlineScript",
166
+ id: `cookie-consent-install-${instanceId}`,
167
+ strategy: "afterInteractive",
168
+ body: [
169
+ "(function() {",
170
+ " var STORAGE_KEY = 'ampless:consent';",
171
+ ` var categoriesConfig = ${categoriesJson};`,
172
+ "",
173
+ " // Restore consent state from localStorage.",
174
+ " var state = {};",
175
+ " try {",
176
+ " var raw = localStorage.getItem(STORAGE_KEY);",
177
+ " if (raw) state = JSON.parse(raw) || {};",
178
+ " } catch (e) { state = {}; }",
179
+ "",
180
+ " // Essential categories are always granted.",
181
+ " for (var i = 0; i < categoriesConfig.length; i++) {",
182
+ " var c = categoriesConfig[i];",
183
+ " if (c.essential) state[c.id] = true;",
184
+ " }",
185
+ "",
186
+ " // Per-category subscriber lists (one-shot on-grant callbacks).",
187
+ " var listeners = {};",
188
+ "",
189
+ " function persist() {",
190
+ " try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}",
191
+ " }",
192
+ "",
193
+ " window.amplessConsent = {",
194
+ " has: function(category) { return state[category] === true; },",
195
+ " isSet: function(category) { return Object.prototype.hasOwnProperty.call(state, category); },",
196
+ " on: function(category, cb) {",
197
+ " if (state[category] === true) {",
198
+ " // Already granted \u2014 fire immediately (one-shot semantics).",
199
+ " try { cb(); } catch (e) {}",
200
+ " return function() {};",
201
+ " }",
202
+ " if (!listeners[category]) listeners[category] = [];",
203
+ " listeners[category].push(cb);",
204
+ " return function() {",
205
+ " var arr = listeners[category];",
206
+ " if (!arr) return;",
207
+ " var idx = arr.indexOf(cb);",
208
+ " if (idx >= 0) arr.splice(idx, 1);",
209
+ " };",
210
+ " },",
211
+ " set: function(category, granted) {",
212
+ " var prev = state[category] === true;",
213
+ " state[category] = granted === true;",
214
+ " persist();",
215
+ " var ev = new CustomEvent('ampless:consent-changed', { detail: { category: category, granted: granted === true } });",
216
+ " window.dispatchEvent(ev);",
217
+ " if (!prev && granted === true && listeners[category]) {",
218
+ " var cbs = listeners[category].slice();",
219
+ " listeners[category] = [];",
220
+ " for (var j = 0; j < cbs.length; j++) {",
221
+ " try { cbs[j](); } catch (e) {}",
222
+ " }",
223
+ " }",
224
+ " },",
225
+ " };",
226
+ "",
227
+ " window.dispatchEvent(new CustomEvent('ampless:consent-ready'));",
228
+ "})();"
229
+ ].join("\n")
230
+ }
231
+ ];
232
+ },
233
+ publicBodyEnd(ctx) {
234
+ const rawCategories = ctx.setting("categories") ?? [];
235
+ const categories = dedupCategoriesById(
236
+ Array.isArray(rawCategories) ? rawCategories : []
237
+ );
238
+ if (categories.length === 0) return [];
239
+ const nonEssential = categories.filter((c) => !c.essential);
240
+ const bannerText = (ctx.setting("bannerText") ?? "").trim() || "This site uses cookies to enhance your browsing experience. You can manage your preferences below.";
241
+ const acceptLabel = (ctx.setting("acceptLabel") ?? "").trim() || "Accept all";
242
+ const rejectLabel = (ctx.setting("rejectLabel") ?? "").trim() || "Reject non-essential";
243
+ const position = ctx.setting("position") ?? "bottom";
244
+ const bannerTextJs = escapeJsString(bannerText);
245
+ const acceptLabelJs = escapeJsString(acceptLabel);
246
+ const rejectLabelJs = escapeJsString(rejectLabel);
247
+ const nonEssentialJson = JSON.stringify(
248
+ nonEssential.map((c) => ({
249
+ id: c.id,
250
+ label: c.label,
251
+ description: c.description ?? "",
252
+ defaultEnabled: c.defaultEnabled ?? false
253
+ }))
254
+ );
255
+ let containerStyle;
256
+ let dialogStyle;
257
+ if (position === "modal") {
258
+ containerStyle = "position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:99999;display:flex;align-items:center;justify-content:center;";
259
+ dialogStyle = "background:#fff;color:#111;padding:24px;border-radius:8px;max-width:480px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,.2);";
260
+ } else if (position === "top") {
261
+ containerStyle = "position:fixed;top:0;left:0;right:0;z-index:99999;";
262
+ dialogStyle = "background:#fff;color:#111;padding:16px;box-shadow:0 2px 8px rgba(0,0,0,.15);";
263
+ } else {
264
+ containerStyle = "position:fixed;bottom:0;left:0;right:0;z-index:99999;";
265
+ dialogStyle = "background:#fff;color:#111;padding:16px;box-shadow:0 -2px 8px rgba(0,0,0,.15);";
266
+ }
267
+ const containerStyleJs = escapeJsString(containerStyle);
268
+ const dialogStyleJs = escapeJsString(dialogStyle);
269
+ return [
270
+ {
271
+ type: "inlineScript",
272
+ id: `cookie-consent-banner-${instanceId}`,
273
+ strategy: "afterInteractive",
274
+ body: [
275
+ "(function() {",
276
+ ` var nonEssential = ${nonEssentialJson};`,
277
+ " if (nonEssential.length === 0) return;",
278
+ "",
279
+ " // Skip banner if every non-essential category has been *decided*",
280
+ " // (accepted OR rejected) on a previous visit. Using `has` here",
281
+ " // would re-show the banner forever after a Reject \u2014 `false` would",
282
+ ' // round-trip as "not yet decided". `isSet` is the explicit',
283
+ " // user-made-a-choice predicate.",
284
+ " if (window.amplessConsent) {",
285
+ " var allDecided = nonEssential.every(function(c) { return window.amplessConsent.isSet(c.id); });",
286
+ " if (allDecided) return;",
287
+ " }",
288
+ "",
289
+ ` var bannerText = '${bannerTextJs}';`,
290
+ ` var acceptLabel = '${acceptLabelJs}';`,
291
+ ` var rejectLabel = '${rejectLabelJs}';`,
292
+ "",
293
+ " // Build banner DOM outside React tree (avoids hydration conflicts).",
294
+ " var container = document.createElement('div');",
295
+ ` container.setAttribute('style', '${containerStyleJs}');`,
296
+ "",
297
+ " var dialog = document.createElement('div');",
298
+ " dialog.setAttribute('role', 'dialog');",
299
+ " dialog.setAttribute('aria-label', 'Cookie consent');",
300
+ ` dialog.setAttribute('style', '${dialogStyleJs}');`,
301
+ "",
302
+ " // Banner text paragraph \u2014 use textContent to avoid XSS.",
303
+ " var p = document.createElement('p');",
304
+ " p.style.margin = '0 0 12px';",
305
+ " p.textContent = bannerText;",
306
+ " dialog.appendChild(p);",
307
+ "",
308
+ " // Category toggles with checkboxes.",
309
+ " // Initial checkbox state precedence:",
310
+ " // 1. user already decided this category \u2192 use their stored choice (has)",
311
+ " // 2. user has NOT decided yet \u2192 use `defaultEnabled` as a UI hint",
312
+ " // `defaultEnabled` is intentionally NOT pre-granted in state \u2014 that would",
313
+ " // bypass the user's explicit consent action, which is incompatible with",
314
+ " // GDPR/ePrivacy. It only seeds the UI checkbox before first interaction.",
315
+ " var toggleState = {};",
316
+ " nonEssential.forEach(function(cat) {",
317
+ " var checked;",
318
+ " if (window.amplessConsent && window.amplessConsent.isSet(cat.id)) {",
319
+ " checked = window.amplessConsent.has(cat.id);",
320
+ " } else {",
321
+ " checked = cat.defaultEnabled === true;",
322
+ " }",
323
+ " toggleState[cat.id] = checked;",
324
+ "",
325
+ " var row = document.createElement('div');",
326
+ " row.style.cssText = 'display:flex;align-items:flex-start;gap:8px;margin-bottom:8px;';",
327
+ "",
328
+ " var checkbox = document.createElement('input');",
329
+ " checkbox.type = 'checkbox';",
330
+ " checkbox.checked = checked;",
331
+ " checkbox.id = 'ampless-consent-cat-' + cat.id;",
332
+ " checkbox.style.marginTop = '2px';",
333
+ " (function(catId) {",
334
+ " checkbox.addEventListener('change', function() {",
335
+ " toggleState[catId] = checkbox.checked;",
336
+ " });",
337
+ " })(cat.id);",
338
+ "",
339
+ " var labelEl = document.createElement('label');",
340
+ " labelEl.htmlFor = 'ampless-consent-cat-' + cat.id;",
341
+ "",
342
+ " var strong = document.createElement('strong');",
343
+ " strong.textContent = cat.label;",
344
+ " labelEl.appendChild(strong);",
345
+ "",
346
+ " if (cat.description) {",
347
+ " var desc = document.createElement('div');",
348
+ " desc.style.cssText = 'font-size:0.85em;color:#555;';",
349
+ " desc.textContent = cat.description;",
350
+ " labelEl.appendChild(desc);",
351
+ " }",
352
+ "",
353
+ " row.appendChild(checkbox);",
354
+ " row.appendChild(labelEl);",
355
+ " dialog.appendChild(row);",
356
+ " });",
357
+ "",
358
+ " // Button row.",
359
+ " var btnRow = document.createElement('div');",
360
+ " btnRow.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;';",
361
+ "",
362
+ " var btnBase = 'cursor:pointer;padding:8px 16px;border:1px solid #333;border-radius:4px;font-size:0.9em;';",
363
+ "",
364
+ " // Accept all.",
365
+ " var acceptBtn = document.createElement('button');",
366
+ " acceptBtn.style.cssText = btnBase + 'background:#111;color:#fff;';",
367
+ " acceptBtn.textContent = acceptLabel;",
368
+ " acceptBtn.addEventListener('click', function() {",
369
+ " nonEssential.forEach(function(cat) {",
370
+ " if (window.amplessConsent) window.amplessConsent.set(cat.id, true);",
371
+ " });",
372
+ " document.body.removeChild(container);",
373
+ " });",
374
+ "",
375
+ " // Save selected (granular toggle state).",
376
+ " var saveBtn = document.createElement('button');",
377
+ " saveBtn.style.cssText = btnBase + 'background:#fff;color:#111;';",
378
+ " saveBtn.textContent = 'Save selected';",
379
+ " saveBtn.addEventListener('click', function() {",
380
+ " nonEssential.forEach(function(cat) {",
381
+ " if (window.amplessConsent) window.amplessConsent.set(cat.id, toggleState[cat.id] === true);",
382
+ " });",
383
+ " document.body.removeChild(container);",
384
+ " });",
385
+ "",
386
+ " // Reject non-essential.",
387
+ " var rejectBtn = document.createElement('button');",
388
+ " rejectBtn.style.cssText = btnBase + 'background:#fff;color:#111;';",
389
+ " rejectBtn.textContent = rejectLabel;",
390
+ " rejectBtn.addEventListener('click', function() {",
391
+ " nonEssential.forEach(function(cat) {",
392
+ " if (window.amplessConsent) window.amplessConsent.set(cat.id, false);",
393
+ " });",
394
+ " document.body.removeChild(container);",
395
+ " });",
396
+ "",
397
+ " btnRow.appendChild(acceptBtn);",
398
+ " btnRow.appendChild(saveBtn);",
399
+ " btnRow.appendChild(rejectBtn);",
400
+ " dialog.appendChild(btnRow);",
401
+ " container.appendChild(dialog);",
402
+ " document.body.appendChild(container);",
403
+ "})();"
404
+ ].join("\n")
405
+ }
406
+ ];
407
+ }
408
+ });
409
+ }
410
+ export {
411
+ cookieConsentPlugin as default
412
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@ampless/plugin-cookie-consent",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Cookie consent banner plugin for ampless — installs window.amplessConsent API and renders a configurable consent banner",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/heavymoons/ampless.git",
24
+ "directory": "packages/plugin-cookie-consent"
25
+ },
26
+ "homepage": "https://github.com/heavymoons/ampless/tree/main/packages/plugin-cookie-consent#readme",
27
+ "bugs": "https://github.com/heavymoons/ampless/issues",
28
+ "dependencies": {
29
+ "ampless": "1.0.0-alpha.26"
30
+ },
31
+ "keywords": [
32
+ "ampless",
33
+ "plugin",
34
+ "ampless-plugin",
35
+ "cookie-consent",
36
+ "gdpr",
37
+ "privacy"
38
+ ],
39
+ "amplessPlugin": {
40
+ "apiVersion": 1,
41
+ "name": "cookie-consent",
42
+ "trustLevel": "untrusted",
43
+ "capabilities": [
44
+ "publicHead",
45
+ "publicBody",
46
+ "adminSettings"
47
+ ],
48
+ "displayName": {
49
+ "en": "Cookie Consent",
50
+ "ja": "Cookie 同意"
51
+ }
52
+ },
53
+ "scripts": {
54
+ "build": "tsup",
55
+ "dev": "tsup --watch",
56
+ "lint": "tsc --noEmit",
57
+ "test": "vitest run --passWithNoTests"
58
+ }
59
+ }