@dengdeng0324/cookie-consent-sdk 1.0.2

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/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @dengdeng0324/cookie-consent-sdk
2
+
3
+ 一个使用 TypeScript、Lit 3 和 Shadow DOM 构建的框架无关 Cookie 同意 SDK。它负责地区模式、UI、状态持久化和明确选择上报;宿主应用根据最终状态自行控制 GA/GTM。
4
+
5
+ ## 快速开始
6
+
7
+ ```bash
8
+ pnpm add @dengdeng0324/cookie-consent-sdk
9
+ ```
10
+
11
+ ```ts
12
+ import cookieManager from '@dengdeng0324/cookie-consent-sdk';
13
+
14
+ const unsubscribe = cookieManager.onDataChange((data) => {
15
+ if (data.preference.analytics) {
16
+ // 宿主在这里加载 Analytics。
17
+ }
18
+ });
19
+
20
+ await cookieManager.init({
21
+ language: 'zh',
22
+ privacyPolicyUrl: '/privacy',
23
+ regionEndpoint: '/api/user/region',
24
+ reportEndpoint: '/api/consent/report',
25
+ });
26
+ ```
27
+
28
+ 可选后端契约:
29
+
30
+ - `GET regionEndpoint`,5 秒超时,失败时回退 `{ countryCode: '', mode: 'opt_in' }`。
31
+ - `POST reportEndpoint`,仅为 `accept_all` 和 `reject_all` 上报 `{ site, mode, country, choice }`。
32
+
33
+ SDK 不内置任何公司域名。端点由宿主通过 `regionEndpoint`、`reportEndpoint` 传入,也可通过 `fetch` 注入 Mock;不传端点时不会发起对应网络请求。
34
+
35
+ ## 公共 API
36
+
37
+ - `init(options)`:初始化并返回首个 `ConsentData`。
38
+ - `onDataChange(listener)`:订阅状态,返回取消订阅函数。
39
+ - `getData()`:获取不可变状态快照。
40
+ - `openPreferences()`:由宿主重新打开偏好中心。
41
+ - `update(options)`:更新语言、主题和政策链接,不重新判断地区。
42
+ - `destroy()`:卸载 UI 并清理订阅。
43
+ - `reset()`:删除本地同意 Cookie 并销毁实例,主要用于 QA。
44
+
45
+ 测试地区可传 `qaRegion: 'DE' | 'US' | 'EU' | 'notice_only'`,或使用页面参数 `?cookie_region=DE`。
46
+
47
+ ## 本地开发
48
+
49
+ ```bash
50
+ pnpm install
51
+ pnpm test
52
+ pnpm build
53
+ pnpm dev
54
+ ```
55
+
56
+ 自定义偏好和关闭 Notice 只写本地 Cookie,不调用上报接口。SDK 不会加载任何分析脚本。
@@ -0,0 +1,124 @@
1
+ type ConsentMode = 'opt_in' | 'notice_only';
2
+ type ConsentView = 'hidden' | 'banner' | 'notice' | 'preferences';
3
+ type ConsentAction = 'accept_all' | 'reject_all' | 'save_preferences' | 'acknowledge';
4
+ interface ConsentPreference {
5
+ necessary: true;
6
+ analytics: boolean;
7
+ marketing: boolean;
8
+ personalization: boolean;
9
+ }
10
+ interface ConsentData {
11
+ countryCode: string;
12
+ mode: ConsentMode;
13
+ hasChoice: boolean;
14
+ preference: ConsentPreference;
15
+ updatedAt?: string;
16
+ }
17
+ interface ThemeOptions {
18
+ primaryColor?: string;
19
+ surfaceColor?: string;
20
+ textColor?: string;
21
+ borderRadius?: string;
22
+ }
23
+ interface ConsentTranslations {
24
+ title: string;
25
+ description: string;
26
+ acceptAll: string;
27
+ rejectAll: string;
28
+ customize: string;
29
+ save: string;
30
+ close: string;
31
+ noticeTitle: string;
32
+ noticeDescription: string;
33
+ acknowledge: string;
34
+ preferencesTitle: string;
35
+ preferencesDescription: string;
36
+ necessary: string;
37
+ necessaryDescription: string;
38
+ analytics: string;
39
+ analyticsDescription: string;
40
+ marketing: string;
41
+ marketingDescription: string;
42
+ personalization: string;
43
+ personalizationDescription: string;
44
+ alwaysOn: string;
45
+ privacyPolicy: string;
46
+ }
47
+ interface RegionResult {
48
+ countryCode: string;
49
+ mode: ConsentMode;
50
+ }
51
+ interface CookieManagerOptions {
52
+ language?: string;
53
+ translations?: Record<string, Partial<ConsentTranslations>>;
54
+ theme?: ThemeOptions;
55
+ privacyPolicyUrl?: string;
56
+ regionEndpoint?: string;
57
+ reportEndpoint?: string;
58
+ cookieName?: string;
59
+ cookieDomain?: string;
60
+ cookieMaxAgeDays?: number;
61
+ mountTarget?: HTMLElement | string;
62
+ requestTimeoutMs?: number;
63
+ fetch?: typeof globalThis.fetch;
64
+ qaRegion?: string;
65
+ }
66
+ type ConsentListener = (data: Readonly<ConsentData>) => void;
67
+ interface StoredConsent extends ConsentData {
68
+ version: 1;
69
+ }
70
+
71
+ declare class CookieManager {
72
+ private options;
73
+ private listeners;
74
+ private data?;
75
+ private view;
76
+ private cookie?;
77
+ private reporter?;
78
+ private i18n?;
79
+ private ui?;
80
+ private initialized;
81
+ private initializing?;
82
+ init(options?: CookieManagerOptions): Promise<Readonly<ConsentData>>;
83
+ onDataChange(listener: ConsentListener): () => void;
84
+ getData(): Readonly<ConsentData> | undefined;
85
+ openPreferences(): void;
86
+ update(options: Partial<CookieManagerOptions>): void;
87
+ destroy(): void;
88
+ reset(): void;
89
+ private initialize;
90
+ private handleAction;
91
+ private save;
92
+ private notify;
93
+ private render;
94
+ private snapshot;
95
+ private qaRegionFromLocation;
96
+ private allPreferences;
97
+ private assertInitialized;
98
+ }
99
+
100
+ declare class ConsentCookie {
101
+ private readonly name;
102
+ private readonly domain?;
103
+ private readonly maxAgeDays;
104
+ constructor(name?: string, domain?: string | undefined, maxAgeDays?: number);
105
+ read(): StoredConsent | undefined;
106
+ write(data: ConsentData): void;
107
+ remove(): void;
108
+ }
109
+
110
+ declare function modeForCountry(countryCode: string): ConsentMode;
111
+ declare function parseQaRegion(value?: string | null): RegionResult | undefined;
112
+ declare class RegionClient {
113
+ private readonly endpoint?;
114
+ private readonly fetcher;
115
+ private readonly timeoutMs;
116
+ constructor(endpoint?: string | undefined, fetcher?: typeof globalThis.fetch, timeoutMs?: number);
117
+ detect(): Promise<RegionResult>;
118
+ }
119
+
120
+ type ReportableChoice = 'accept_all' | 'reject_all';
121
+
122
+ declare const cookieManager: CookieManager;
123
+
124
+ export { type ConsentAction, ConsentCookie, type ConsentData, type ConsentListener, type ConsentMode, type ConsentPreference, type ConsentTranslations, type ConsentView, CookieManager, type CookieManagerOptions, RegionClient, type RegionResult, type ReportableChoice, type StoredConsent, type ThemeOptions, cookieManager, cookieManager as default, modeForCountry, parseQaRegion };
package/dist/index.js ADDED
@@ -0,0 +1,681 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __decorateClass = (decorators, target, key, kind) => {
4
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
5
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
6
+ if (decorator = decorators[i])
7
+ result = (kind ? decorator(target, key, result) : decorator(result)) || result;
8
+ if (kind && result) __defProp(target, key, result);
9
+ return result;
10
+ };
11
+
12
+ // src/defaults.ts
13
+ var DEFAULT_PREFERENCE = {
14
+ necessary: true,
15
+ analytics: false,
16
+ marketing: false,
17
+ personalization: false
18
+ };
19
+ var EN_TRANSLATIONS = {
20
+ title: "Your privacy choices",
21
+ description: "We use cookies to keep this site working and, with your permission, to understand usage and personalize your experience.",
22
+ acceptAll: "Accept all",
23
+ rejectAll: "Reject all",
24
+ customize: "Manage preferences",
25
+ save: "Save Preferences",
26
+ close: "Close",
27
+ noticeTitle: "Cookie notice",
28
+ noticeDescription: "We use cookies to improve your experience on our platform. By continuing to use our site, you agree to our use of cookies.",
29
+ acknowledge: "Got It",
30
+ preferencesTitle: "Cookie Preferences",
31
+ preferencesDescription: "Choose which optional cookies you allow. Strictly necessary cookies cannot be disabled.",
32
+ necessary: "Strictly Necessary Cookies",
33
+ necessaryDescription: "These cookies are essential for the platform to function and cannot be switched off. They are set in response to actions you take, such as logging in or saving your preferences, and cannot be disabled.",
34
+ analytics: "Analytics Cookies",
35
+ analyticsDescription: "These cookies help us understand how visitors use our platform by collecting anonymized usage data. This information helps us improve our products and user experience.",
36
+ marketing: "Advertising Cookies",
37
+ marketingDescription: "These cookies are set by our advertising partners to build a profile of your interests and show you relevant ads on other sites. They do not store personal data directly but uniquely identify your browser and device.",
38
+ personalization: "Functional Cookies",
39
+ personalizationDescription: "These cookies allow the platform to remember your choices and provide enhanced, personalized features such as language preferences and UI settings. Disabling them may affect some platform functionality.",
40
+ alwaysOn: "Always On",
41
+ privacyPolicy: "Learn more"
42
+ };
43
+ var ZH_TRANSLATIONS = {
44
+ title: "\u60A8\u7684\u9690\u79C1\u9009\u62E9",
45
+ description: "\u6211\u4EEC\u4F7F\u7528 Cookie \u6765\u7EF4\u6301\u7F51\u7AD9\u8FD0\u884C\uFF1B\u7ECF\u60A8\u5141\u8BB8\u540E\uFF0C\u8FD8\u4F1A\u7528\u4E8E\u5206\u6790\u4F7F\u7528\u60C5\u51B5\u5E76\u63D0\u4F9B\u4E2A\u6027\u5316\u4F53\u9A8C\u3002",
46
+ acceptAll: "\u5168\u90E8\u63A5\u53D7",
47
+ rejectAll: "\u5168\u90E8\u62D2\u7EDD",
48
+ customize: "\u7BA1\u7406\u504F\u597D",
49
+ save: "\u4FDD\u5B58\u504F\u597D",
50
+ close: "\u5173\u95ED",
51
+ noticeTitle: "Cookie \u901A\u77E5",
52
+ noticeDescription: "\u6211\u4EEC\u4F7F\u7528 Cookie \u6539\u5584\u60A8\u5728\u5E73\u53F0\u4E0A\u7684\u4F53\u9A8C\u3002\u7EE7\u7EED\u4F7F\u7528\u672C\u7F51\u7AD9\u5373\u8868\u793A\u60A8\u540C\u610F\u6211\u4EEC\u4F7F\u7528 Cookie\u3002",
53
+ acknowledge: "\u77E5\u9053\u4E86",
54
+ preferencesTitle: "Cookie \u504F\u597D\u8BBE\u7F6E",
55
+ preferencesDescription: "\u8BF7\u9009\u62E9\u5141\u8BB8\u4F7F\u7528\u7684\u53EF\u9009 Cookie\u3002\u4E25\u683C\u5FC5\u8981 Cookie \u65E0\u6CD5\u5173\u95ED\u3002",
56
+ necessary: "\u4E25\u683C\u5FC5\u8981 Cookie",
57
+ necessaryDescription: "\u8FD9\u4E9B Cookie \u662F\u5E73\u53F0\u6B63\u5E38\u8FD0\u884C\u6240\u5FC5\u9700\u7684\uFF0C\u65E0\u6CD5\u5173\u95ED\u3002\u5B83\u4EEC\u4F1A\u5728\u60A8\u767B\u5F55\u6216\u4FDD\u5B58\u504F\u597D\u8BBE\u7F6E\u7B49\u64CD\u4F5C\u65F6\u542F\u7528\u3002",
58
+ analytics: "\u5206\u6790 Cookie",
59
+ analyticsDescription: "\u8FD9\u4E9B Cookie \u901A\u8FC7\u6536\u96C6\u533F\u540D\u4F7F\u7528\u6570\u636E\uFF0C\u5E2E\u52A9\u6211\u4EEC\u4E86\u89E3\u8BBF\u5BA2\u5982\u4F55\u4F7F\u7528\u5E73\u53F0\u5E76\u6539\u5584\u4EA7\u54C1\u4F53\u9A8C\u3002",
60
+ marketing: "\u5E7F\u544A Cookie",
61
+ marketingDescription: "\u8FD9\u4E9B Cookie \u7528\u4E8E\u5EFA\u7ACB\u5174\u8DA3\u753B\u50CF\u5E76\u5728\u5176\u4ED6\u7F51\u7AD9\u4E0A\u5C55\u793A\u76F8\u5173\u5E7F\u544A\u3002",
62
+ personalization: "\u529F\u80FD Cookie",
63
+ personalizationDescription: "\u8FD9\u4E9B Cookie \u4F1A\u8BB0\u4F4F\u8BED\u8A00\u548C\u754C\u9762\u8BBE\u7F6E\u7B49\u9009\u62E9\uFF0C\u4EE5\u63D0\u4F9B\u589E\u5F3A\u7684\u4E2A\u6027\u5316\u529F\u80FD\u3002",
64
+ alwaysOn: "\u59CB\u7EC8\u542F\u7528",
65
+ privacyPolicy: "\u4E86\u89E3\u66F4\u591A"
66
+ };
67
+
68
+ // src/consent-cookie.ts
69
+ import Cookies from "js-cookie";
70
+ var ConsentCookie = class {
71
+ constructor(name = "cookie_consent", domain, maxAgeDays = 180) {
72
+ this.name = name;
73
+ this.domain = domain;
74
+ this.maxAgeDays = maxAgeDays;
75
+ }
76
+ read() {
77
+ const raw = Cookies.get(this.name);
78
+ if (!raw) return void 0;
79
+ try {
80
+ const value = JSON.parse(raw);
81
+ if (!isStoredConsent(value)) return void 0;
82
+ return {
83
+ ...value,
84
+ countryCode: value.countryCode.trim().toUpperCase() || "EU",
85
+ hasChoice: true,
86
+ preference: { ...value.preference, necessary: true }
87
+ };
88
+ } catch {
89
+ return void 0;
90
+ }
91
+ }
92
+ write(data) {
93
+ const value = { ...data, version: 1, hasChoice: true };
94
+ Cookies.set(this.name, JSON.stringify(value), {
95
+ expires: this.maxAgeDays,
96
+ sameSite: "Lax",
97
+ secure: globalThis.location?.protocol === "https:",
98
+ domain: this.domain
99
+ });
100
+ }
101
+ remove() {
102
+ Cookies.remove(this.name, { domain: this.domain });
103
+ }
104
+ };
105
+ function isStoredConsent(value) {
106
+ const preference = value.preference;
107
+ return value.version === 1 && typeof value.countryCode === "string" && (value.mode === "opt_in" || value.mode === "notice_only") && typeof preference === "object" && preference !== null && typeof preference.analytics === "boolean" && typeof preference.marketing === "boolean" && typeof preference.personalization === "boolean";
108
+ }
109
+
110
+ // src/i18n.ts
111
+ import i18next from "i18next";
112
+ var ConsentI18n = class {
113
+ constructor(language = "en", custom = {}) {
114
+ this.instance = i18next.createInstance();
115
+ const languages = /* @__PURE__ */ new Set(["en", "zh", ...Object.keys(custom)]);
116
+ const resources = Object.fromEntries([...languages].map((code) => {
117
+ const base = code.startsWith("zh") ? ZH_TRANSLATIONS : EN_TRANSLATIONS;
118
+ return [code, { translation: { ...base, ...custom[code] } }];
119
+ }));
120
+ void this.instance.init({ lng: language, fallbackLng: "en", resources, initImmediate: false });
121
+ }
122
+ setLanguage(language) {
123
+ void this.instance.changeLanguage(language);
124
+ }
125
+ text(key) {
126
+ return this.instance.t(key);
127
+ }
128
+ get language() {
129
+ return this.instance.language;
130
+ }
131
+ };
132
+
133
+ // src/region-client.ts
134
+ var OPT_IN_COUNTRIES = /* @__PURE__ */ new Set([
135
+ "AT",
136
+ "BE",
137
+ "BG",
138
+ "HR",
139
+ "CY",
140
+ "CZ",
141
+ "DK",
142
+ "EE",
143
+ "FI",
144
+ "FR",
145
+ "DE",
146
+ "GR",
147
+ "HU",
148
+ "IE",
149
+ "IT",
150
+ "LV",
151
+ "LT",
152
+ "LU",
153
+ "MT",
154
+ "NL",
155
+ "PL",
156
+ "PT",
157
+ "RO",
158
+ "SK",
159
+ "SI",
160
+ "ES",
161
+ "SE",
162
+ "IS",
163
+ "LI",
164
+ "NO",
165
+ "GB"
166
+ ]);
167
+ var PRIVACY_SAFE_REGION = Object.freeze({
168
+ countryCode: "EU",
169
+ mode: "opt_in"
170
+ });
171
+ function modeForCountry(countryCode) {
172
+ return OPT_IN_COUNTRIES.has(countryCode.toUpperCase()) ? "opt_in" : "notice_only";
173
+ }
174
+ function parseQaRegion(value) {
175
+ if (!value) return void 0;
176
+ const normalized = value.trim().toUpperCase();
177
+ if (["EU", "EEA", "OPT_IN"].includes(normalized)) return { countryCode: "EU", mode: "opt_in" };
178
+ if (["NON_EU", "NOTICE", "NOTICE_ONLY"].includes(normalized)) return { countryCode: "US", mode: "notice_only" };
179
+ if (/^[A-Z]{2}$/.test(normalized)) return { countryCode: normalized, mode: modeForCountry(normalized) };
180
+ return void 0;
181
+ }
182
+ var RegionClient = class {
183
+ constructor(endpoint, fetcher = globalThis.fetch, timeoutMs = 5e3) {
184
+ this.endpoint = endpoint;
185
+ this.fetcher = fetcher;
186
+ this.timeoutMs = timeoutMs;
187
+ }
188
+ async detect() {
189
+ if (!this.endpoint) return { ...PRIVACY_SAFE_REGION };
190
+ const controller = new AbortController();
191
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
192
+ try {
193
+ const response = await this.fetcher(this.endpoint, {
194
+ signal: controller.signal,
195
+ credentials: "include",
196
+ headers: { Accept: "application/json" }
197
+ });
198
+ if (!response.ok) throw new Error(`Region request failed (${response.status})`);
199
+ const body = await response.json();
200
+ const countryCode = body.countryCode?.toUpperCase();
201
+ if (!countryCode) throw new Error("Region response is missing countryCode");
202
+ return { countryCode, mode: body.mode ?? modeForCountry(countryCode) };
203
+ } finally {
204
+ clearTimeout(timer);
205
+ }
206
+ }
207
+ };
208
+
209
+ // src/report-client.ts
210
+ var ConsentReportClient = class {
211
+ constructor(endpoint, fetcher = globalThis.fetch, timeoutMs = 5e3) {
212
+ this.endpoint = endpoint;
213
+ this.fetcher = fetcher;
214
+ this.timeoutMs = timeoutMs;
215
+ }
216
+ async report(choice, data) {
217
+ if (!this.endpoint) return;
218
+ const controller = new AbortController();
219
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
220
+ try {
221
+ const response = await this.fetcher(this.endpoint, {
222
+ method: "POST",
223
+ headers: { "content-type": "application/json" },
224
+ credentials: "include",
225
+ keepalive: true,
226
+ signal: controller.signal,
227
+ body: JSON.stringify({
228
+ site: globalThis.location?.hostname ?? "",
229
+ mode: data.mode,
230
+ country: data.countryCode,
231
+ choice
232
+ })
233
+ });
234
+ if (!response.ok) throw new Error(`Consent report failed (${response.status})`);
235
+ } finally {
236
+ clearTimeout(timer);
237
+ }
238
+ }
239
+ };
240
+
241
+ // src/ui/cookie-consent-manager.ts
242
+ import { LitElement, css, html, nothing } from "lit";
243
+ import { customElement, property, state } from "lit/decorators.js";
244
+ var CookieConsentManagerElement = class extends LitElement {
245
+ constructor() {
246
+ super(...arguments);
247
+ this.translations = EN_TRANSLATIONS;
248
+ this.theme = {};
249
+ this.view = "hidden";
250
+ this.privacyPolicyUrl = "";
251
+ this.draft = {
252
+ necessary: true,
253
+ analytics: false,
254
+ marketing: false,
255
+ personalization: false
256
+ };
257
+ }
258
+ willUpdate(changed) {
259
+ if (changed.has("data") && this.data) this.draft = { ...this.data.preference };
260
+ }
261
+ emit(action, preference) {
262
+ this.dispatchEvent(new CustomEvent("cookie-consent-action", {
263
+ detail: { action, preference },
264
+ bubbles: true,
265
+ composed: true
266
+ }));
267
+ }
268
+ toggle(key, checked) {
269
+ this.draft = { ...this.draft, [key]: checked };
270
+ }
271
+ styleVariables() {
272
+ return [
273
+ `--cc-primary:${this.theme.primaryColor ?? "#20bf63"}`,
274
+ `--cc-surface:${this.theme.surfaceColor ?? "#ffffff"}`,
275
+ `--cc-text:${this.theme.textColor ?? "#172033"}`,
276
+ `--cc-radius:${this.theme.borderRadius ?? "16px"}`
277
+ ].join(";");
278
+ }
279
+ render() {
280
+ if (!this.data || this.view === "hidden") return nothing;
281
+ return html`<div style=${this.styleVariables()}>
282
+ ${this.view === "banner" ? this.renderBanner() : nothing}
283
+ ${this.view === "notice" ? this.renderNotice() : nothing}
284
+ ${this.view === "preferences" ? this.renderPreferences() : nothing}
285
+ </div>`;
286
+ }
287
+ renderBanner() {
288
+ const t = this.translations;
289
+ return html`<section class="banner" role="dialog" aria-modal="false" aria-labelledby="cc-title">
290
+ <h2 id="cc-title" class="sr-only">${t.title}</h2>
291
+ <button class="close" aria-label="${t.rejectAll} — ${t.close}" @click=${() => this.emit("reject_all")}>×</button>
292
+ <p>${t.description} ${this.policyLink()}</p>
293
+ <div class="eu-actions">
294
+ <button class="primary eu-accept" @click=${() => this.emit("accept_all")}>${t.acceptAll}</button>
295
+ <button @click=${() => this.emit("reject_all")}>${t.rejectAll}</button>
296
+ <button @click=${() => this.emit("open_preferences")}>${t.customize}</button>
297
+ </div>
298
+ </section>`;
299
+ }
300
+ renderNotice() {
301
+ const t = this.translations;
302
+ return html`<aside class="notice" role="dialog" aria-labelledby="cc-notice-title">
303
+ <h2 id="cc-notice-title" class="sr-only">${t.noticeTitle}</h2>
304
+ <button class="close" aria-label=${t.close} @click=${() => this.emit("acknowledge")}>×</button>
305
+ <p>${t.noticeDescription} ${this.policyLink()}</p>
306
+ <button class="notice-acknowledge" @click=${() => this.emit("acknowledge")}>${t.acknowledge}</button>
307
+ </aside>`;
308
+ }
309
+ renderPreferences() {
310
+ const t = this.translations;
311
+ return html`<div class="backdrop" @click=${(event) => {
312
+ if (event.target === event.currentTarget) this.emit("close_preferences");
313
+ }}>
314
+ <section class="modal" role="dialog" aria-modal="true" aria-labelledby="cc-preferences-title">
315
+ <header>
316
+ <h2 id="cc-preferences-title">${t.preferencesTitle}</h2>
317
+ <button class="close modal-close" aria-label=${t.close} @click=${() => this.emit("close_preferences")}>×</button>
318
+ </header>
319
+ <div class="categories">
320
+ ${this.category("necessary", t.necessary, t.necessaryDescription, true)}
321
+ ${this.category("personalization", t.personalization, t.personalizationDescription, this.draft.personalization)}
322
+ ${this.category("analytics", t.analytics, t.analyticsDescription, this.draft.analytics)}
323
+ ${this.category("marketing", t.marketing, t.marketingDescription, this.draft.marketing)}
324
+ </div>
325
+ <footer>
326
+ <button class="save" @click=${() => this.emit("save_preferences", this.draft)}>${t.save}</button>
327
+ </footer>
328
+ </section>
329
+ </div>`;
330
+ }
331
+ category(key, title, description, checked) {
332
+ const necessary = key === "necessary";
333
+ return html`<section class="category">
334
+ <div class="category-head">
335
+ <div class="category-title">
336
+ <strong>${title}</strong>
337
+ ${necessary ? html`<span class="always">${this.translations.alwaysOn}</span>` : nothing}
338
+ </div>
339
+ <input
340
+ type="checkbox"
341
+ aria-label=${title}
342
+ .checked=${checked}
343
+ ?disabled=${necessary}
344
+ @change=${(event) => !necessary && this.toggle(key, event.target.checked)}
345
+ />
346
+ </div>
347
+ <p>${description}</p>
348
+ </section>`;
349
+ }
350
+ policyLink() {
351
+ return this.privacyPolicyUrl ? html`<a href=${this.privacyPolicyUrl} target="_blank" rel="noopener">${this.translations.privacyPolicy}</a>` : nothing;
352
+ }
353
+ };
354
+ CookieConsentManagerElement.styles = css`
355
+ :host { font-family: Arial, Helvetica, ui-sans-serif, system-ui, sans-serif; color: var(--cc-text); }
356
+ * { box-sizing: border-box; }
357
+ .banner { position: fixed; z-index: 2147483000; right: 24px; bottom: 24px; width: min(440px, calc(100vw - 32px)); padding: 44px 24px 22px; background: var(--cc-surface); color: #171717; border: 1px solid #dedede; border-radius: 14px; box-shadow: 0 10px 28px rgba(0, 0, 0, .16); }
358
+ .banner p { margin: 0; color: #171717; font-size: 16px; line-height: 1.5; }
359
+ .eu-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
360
+ .eu-actions button { width: 100%; height: 44px; min-height: 44px; padding-inline: 12px; border-color: #dedede; border-radius: 12px; background: #fff; color: #171717; font-size: 15px; font-weight: 400; }
361
+ .eu-actions .eu-accept { grid-column: 1 / -1; border-color: var(--cc-primary); background: var(--cc-primary); color: #fff; }
362
+ .copy { flex: 1; min-width: 0; }
363
+ .eyebrow { color: var(--cc-primary); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
364
+ h2 { margin: 5px 0 8px; font-size: 22px; line-height: 1.2; }
365
+ p { margin: 0; color: color-mix(in srgb, var(--cc-text), transparent 25%); font-size: 14px; line-height: 1.55; }
366
+ a { color: currentColor; text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
367
+ .actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
368
+ button { appearance: none; min-height: 42px; padding: 0 18px; border: 1px solid color-mix(in srgb, var(--cc-text), transparent 76%); border-radius: calc(var(--cc-radius) * .55); background: var(--cc-surface); color: var(--cc-text); font: inherit; font-weight: 700; cursor: pointer; }
369
+ button:hover { filter: brightness(.97); transform: translateY(-1px); }
370
+ button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid color-mix(in srgb, var(--cc-primary), transparent 60%); outline-offset: 2px; }
371
+ button.primary { border-color: var(--cc-primary); background: var(--cc-primary); color: white; }
372
+ button.link { border-color: transparent; background: transparent; color: var(--cc-primary); padding-inline: 8px; }
373
+ .notice { position: fixed; z-index: 2147483000; right: 24px; bottom: 24px; width: min(440px, calc(100vw - 32px)); padding: 44px 24px 22px; background: var(--cc-surface); color: #171717; border: 1px solid #dedede; border-radius: 14px; box-shadow: 0 10px 28px rgba(0, 0, 0, .16); }
374
+ .notice p { margin: 0 0 18px; color: #171717; font-size: 16px; line-height: 1.5; }
375
+ .notice-acknowledge { width: 100%; height: 44px; min-height: 44px; border-color: #dedede; border-radius: 12px; background: #fff; color: #171717; font-size: 15px; font-weight: 400; }
376
+ .close { position: absolute; top: 14px; right: 16px; width: 26px; min-height: 26px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: #111; font-size: 24px; font-weight: 400; line-height: 1; }
377
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
378
+ .backdrop { position: fixed; z-index: 2147483000; inset: 0; display: grid; place-items: center; padding: 32px; background: rgba(15, 23, 42, .28); backdrop-filter: blur(3px); }
379
+ .modal { position: relative; display: flex; flex-direction: column; width: min(640px, 100%); max-height: calc(100vh - 64px); overflow: auto; padding: 24px; background: #fff; color: #0d0d0f; border: 1px solid #dedede; border-radius: 16px; box-shadow: 0 18px 48px rgba(15, 23, 42, .18); }
380
+ header { display: flex; justify-content: space-between; align-items: center; min-height: 36px; }
381
+ header h2 { margin: 0; font-size: 26px; font-weight: 700; line-height: 1.2; letter-spacing: -.02em; }
382
+ .modal-close { top: 25px; right: 24px; }
383
+ .categories { margin: 16px 0 20px; }
384
+ .category { padding: 14px 0; border-bottom: 1px solid #d9d9dd; }
385
+ .category + .category { padding-top: 16px; }
386
+ .category-head { display: flex; align-items: center; justify-content: space-between; gap: 22px; }
387
+ .category-title { display: flex; align-items: center; gap: 14px; min-width: 0; }
388
+ .category strong { font-size: 18px; font-weight: 700; line-height: 1.25; }
389
+ .category p { margin: 8px 0 0; color: #70717e; font-size: 15px; line-height: 1.5; }
390
+ .always { display: inline-flex; align-items: center; min-height: 25px; padding: 2px 10px; border-radius: 999px; background: var(--cc-primary); color: #fff; font-size: 13px; font-weight: 400; white-space: nowrap; }
391
+ input { appearance: none; flex: 0 0 54px; width: 54px; height: 30px; margin: 0; border-radius: 999px; background: #aeb7c8; cursor: pointer; transition: .2s; }
392
+ input::before { content: ''; display: block; width: 25px; height: 25px; margin: 2.5px; border-radius: 50%; background: white; box-shadow: 0 1px 3px #0003; transition: .2s; }
393
+ input:checked { background: var(--cc-primary); }
394
+ input:checked::before { transform: translateX(24px); }
395
+ input:disabled { cursor: default; opacity: .42; }
396
+ footer { display: flex; justify-content: flex-end; margin-top: auto; }
397
+ button.save { min-width: 168px; height: 48px; border-color: #dedee4; border-radius: 12px; background: #fff; color: #111; font-size: 16px; font-weight: 400; }
398
+ @media (max-width: 640px) {
399
+ .banner { right: 12px; bottom: 12px; left: 12px; width: auto; padding: 42px 20px 20px; }
400
+ .banner p { font-size: 15px; }
401
+ .eu-actions { grid-template-columns: 1fr; }
402
+ .eu-actions .eu-accept { grid-column: auto; }
403
+ .notice { right: 12px; bottom: 12px; left: 12px; width: auto; padding: 42px 20px 20px; }
404
+ .notice p { font-size: 15px; }
405
+ .actions { display: grid; grid-template-columns: 1fr; }
406
+ .actions button { width: 100%; }
407
+ .modal { height: auto; max-height: calc(100vh - 24px); padding: 24px 22px; }
408
+ header h2 { font-size: 27px; }
409
+ .modal-close { top: 22px; right: 20px; }
410
+ .category strong { font-size: 19px; }
411
+ .category p { font-size: 16px; }
412
+ .always { min-height: 26px; padding-inline: 10px; font-size: 14px; }
413
+ input { flex-basis: 54px; width: 54px; height: 31px; }
414
+ input::before { width: 26px; height: 26px; }
415
+ input:checked::before { transform: translateX(23px); }
416
+ button.save { width: 100%; }
417
+ }
418
+ @media (prefers-reduced-motion: no-preference) {
419
+ .banner { animation: notice-appear .25s ease-out; }
420
+ .notice { animation: notice-appear .25s ease-out; }
421
+ .modal { animation: appear .25s ease-out; }
422
+ @keyframes rise { from { opacity: 0; transform: translateY(18px); } }
423
+ @keyframes notice-appear { from { opacity: 0; transform: translateY(10px); } }
424
+ @keyframes appear { from { opacity: 0; transform: translateY(10px) scale(.98); } }
425
+ }
426
+ `;
427
+ __decorateClass([
428
+ property({ attribute: false })
429
+ ], CookieConsentManagerElement.prototype, "data", 2);
430
+ __decorateClass([
431
+ property({ attribute: false })
432
+ ], CookieConsentManagerElement.prototype, "translations", 2);
433
+ __decorateClass([
434
+ property({ attribute: false })
435
+ ], CookieConsentManagerElement.prototype, "theme", 2);
436
+ __decorateClass([
437
+ property({ attribute: false })
438
+ ], CookieConsentManagerElement.prototype, "view", 2);
439
+ __decorateClass([
440
+ property({ type: String })
441
+ ], CookieConsentManagerElement.prototype, "privacyPolicyUrl", 2);
442
+ __decorateClass([
443
+ state()
444
+ ], CookieConsentManagerElement.prototype, "draft", 2);
445
+ CookieConsentManagerElement = __decorateClass([
446
+ customElement("cookie-consent-manager")
447
+ ], CookieConsentManagerElement);
448
+
449
+ // src/ui-controller.ts
450
+ var UIController = class {
451
+ constructor(options, i18n) {
452
+ this.options = options;
453
+ this.i18n = i18n;
454
+ this.onAction = (event) => this.handler?.(event.detail);
455
+ }
456
+ mount(handler) {
457
+ if (this.element) return;
458
+ const target = this.resolveTarget();
459
+ this.handler = handler;
460
+ this.element = document.createElement("cookie-consent-manager");
461
+ this.element.addEventListener("cookie-consent-action", this.onAction);
462
+ target.append(this.element);
463
+ }
464
+ render(data, view) {
465
+ if (!this.element) return;
466
+ this.element.data = data;
467
+ this.element.view = view;
468
+ this.element.theme = this.options.theme ?? {};
469
+ this.element.privacyPolicyUrl = this.options.privacyPolicyUrl ?? "";
470
+ this.element.translations = this.translationSnapshot();
471
+ }
472
+ update(options) {
473
+ this.options = { ...this.options, ...options, theme: { ...this.options.theme, ...options.theme } };
474
+ }
475
+ destroy() {
476
+ this.element?.removeEventListener("cookie-consent-action", this.onAction);
477
+ this.element?.remove();
478
+ this.element = void 0;
479
+ }
480
+ resolveTarget() {
481
+ if (this.options.mountTarget instanceof HTMLElement) return this.options.mountTarget;
482
+ if (typeof this.options.mountTarget === "string") {
483
+ const target = document.querySelector(this.options.mountTarget);
484
+ if (target) return target;
485
+ }
486
+ return document.body;
487
+ }
488
+ translationSnapshot() {
489
+ const keys = [
490
+ "title",
491
+ "description",
492
+ "acceptAll",
493
+ "rejectAll",
494
+ "customize",
495
+ "save",
496
+ "close",
497
+ "noticeTitle",
498
+ "noticeDescription",
499
+ "acknowledge",
500
+ "preferencesTitle",
501
+ "preferencesDescription",
502
+ "necessary",
503
+ "necessaryDescription",
504
+ "analytics",
505
+ "analyticsDescription",
506
+ "marketing",
507
+ "marketingDescription",
508
+ "personalization",
509
+ "personalizationDescription",
510
+ "alwaysOn",
511
+ "privacyPolicy"
512
+ ];
513
+ return Object.fromEntries(keys.map((key) => [key, this.i18n.text(key)]));
514
+ }
515
+ };
516
+
517
+ // src/cookie-manager.ts
518
+ var CookieManager = class {
519
+ constructor() {
520
+ this.options = {};
521
+ this.listeners = /* @__PURE__ */ new Set();
522
+ this.view = "hidden";
523
+ this.initialized = false;
524
+ }
525
+ init(options = {}) {
526
+ if (this.initializing) return this.initializing;
527
+ this.initializing = this.initialize(options);
528
+ return this.initializing;
529
+ }
530
+ onDataChange(listener) {
531
+ this.listeners.add(listener);
532
+ if (this.data) listener(this.snapshot());
533
+ return () => this.listeners.delete(listener);
534
+ }
535
+ getData() {
536
+ return this.data ? this.snapshot() : void 0;
537
+ }
538
+ openPreferences() {
539
+ this.assertInitialized();
540
+ this.view = "preferences";
541
+ this.render();
542
+ }
543
+ update(options) {
544
+ this.assertInitialized();
545
+ this.options = {
546
+ ...this.options,
547
+ ...options,
548
+ theme: { ...this.options.theme, ...options.theme },
549
+ translations: { ...this.options.translations, ...options.translations }
550
+ };
551
+ if (options.language) this.i18n?.setLanguage(options.language);
552
+ this.ui?.update(this.options);
553
+ this.render();
554
+ }
555
+ destroy() {
556
+ this.ui?.destroy();
557
+ this.listeners.clear();
558
+ this.ui = void 0;
559
+ this.initialized = false;
560
+ this.initializing = void 0;
561
+ }
562
+ reset() {
563
+ this.cookie?.remove();
564
+ this.destroy();
565
+ this.data = void 0;
566
+ this.view = "hidden";
567
+ }
568
+ async initialize(options) {
569
+ this.options = options;
570
+ const fetcher = options.fetch ?? globalThis.fetch;
571
+ const timeout = options.requestTimeoutMs ?? 5e3;
572
+ this.cookie = new ConsentCookie(options.cookieName, options.cookieDomain, options.cookieMaxAgeDays);
573
+ this.reporter = new ConsentReportClient(options.reportEndpoint, fetcher, timeout);
574
+ this.i18n = new ConsentI18n(options.language, options.translations);
575
+ const qa = parseQaRegion(options.qaRegion ?? this.qaRegionFromLocation());
576
+ const stored = qa ? void 0 : this.cookie.read();
577
+ if (stored) {
578
+ this.data = { ...stored, preference: { ...stored.preference, necessary: true } };
579
+ } else {
580
+ let region = qa;
581
+ if (!region) {
582
+ const client = new RegionClient(options.regionEndpoint, fetcher, timeout);
583
+ try {
584
+ region = await client.detect();
585
+ } catch {
586
+ region = { ...PRIVACY_SAFE_REGION };
587
+ }
588
+ }
589
+ const preference = region.mode === "notice_only" ? this.allPreferences(true) : { ...DEFAULT_PREFERENCE };
590
+ this.data = { ...region, hasChoice: false, preference };
591
+ }
592
+ if (typeof document !== "undefined") {
593
+ this.ui = new UIController(this.options, this.i18n);
594
+ this.ui.mount((detail) => this.handleAction(detail));
595
+ }
596
+ this.initialized = true;
597
+ this.view = this.data.hasChoice ? "hidden" : this.data.mode === "opt_in" ? "banner" : "notice";
598
+ this.render();
599
+ this.notify();
600
+ return this.snapshot();
601
+ }
602
+ handleAction(detail) {
603
+ switch (detail.action) {
604
+ case "open_preferences":
605
+ this.view = "preferences";
606
+ this.render();
607
+ return;
608
+ case "close_preferences":
609
+ this.view = this.data?.hasChoice ? "hidden" : this.data?.mode === "opt_in" ? "banner" : "notice";
610
+ this.render();
611
+ return;
612
+ case "accept_all":
613
+ this.save(this.allPreferences(true), "accept_all");
614
+ return;
615
+ case "reject_all":
616
+ this.save(this.allPreferences(false), "reject_all");
617
+ return;
618
+ case "save_preferences":
619
+ if (detail.preference) this.save(detail.preference);
620
+ return;
621
+ case "acknowledge":
622
+ this.save(this.data?.preference ?? this.allPreferences(true));
623
+ }
624
+ }
625
+ save(preference, reportChoice) {
626
+ if (!this.data) return;
627
+ this.data = {
628
+ ...this.data,
629
+ hasChoice: true,
630
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
631
+ preference: { ...preference, necessary: true }
632
+ };
633
+ this.cookie?.write(this.data);
634
+ this.view = "hidden";
635
+ this.render();
636
+ this.notify();
637
+ if (reportChoice) void this.reporter?.report(reportChoice, this.data).catch(() => void 0);
638
+ }
639
+ notify() {
640
+ const snapshot = this.snapshot();
641
+ for (const listener of this.listeners) {
642
+ try {
643
+ listener(snapshot);
644
+ } catch {
645
+ }
646
+ }
647
+ if (typeof window !== "undefined") {
648
+ window.dispatchEvent(new CustomEvent("cookie-consent:change", { detail: snapshot }));
649
+ }
650
+ }
651
+ render() {
652
+ if (this.data) this.ui?.render(this.snapshot(), this.view);
653
+ }
654
+ snapshot() {
655
+ if (!this.data) throw new Error("CookieManager has not initialized");
656
+ return Object.freeze({ ...this.data, preference: Object.freeze({ ...this.data.preference }) });
657
+ }
658
+ qaRegionFromLocation() {
659
+ if (typeof location === "undefined") return null;
660
+ return new URLSearchParams(location.search).get("cookie_region");
661
+ }
662
+ allPreferences(value) {
663
+ return { necessary: true, analytics: value, marketing: value, personalization: value };
664
+ }
665
+ assertInitialized() {
666
+ if (!this.initialized) throw new Error("Call cookieManager.init() first");
667
+ }
668
+ };
669
+
670
+ // src/index.ts
671
+ var cookieManager = new CookieManager();
672
+ var index_default = cookieManager;
673
+ export {
674
+ ConsentCookie,
675
+ CookieManager,
676
+ RegionClient,
677
+ cookieManager,
678
+ index_default as default,
679
+ modeForCountry,
680
+ parseQaRegion
681
+ };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@dengdeng0324/cookie-consent-sdk",
3
+ "version": "1.0.2",
4
+ "description": "Framework-agnostic cookie consent state machine and Lit Web Components UI",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Pubfree-Labs/cookie-consent-sdk.git"
8
+ },
9
+ "homepage": "https://github.com/Pubfree-Labs/cookie-consent-sdk#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/Pubfree-Labs/cookie-consent-sdk/issues"
12
+ },
13
+ "type": "module",
14
+ "main": "./dist/index.js",
15
+ "module": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
18
+ "files": ["dist", "README.md"],
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "registry": "https://registry.npmjs.org/"
22
+ },
23
+ "sideEffects": true,
24
+ "scripts": {
25
+ "build": "tsup src/index.ts --format esm --dts --clean",
26
+ "prepare": "npm run build",
27
+ "dev": "vite demo",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "i18next": "^25.3.0",
34
+ "js-cookie": "^3.0.5",
35
+ "lit": "^3.3.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/js-cookie": "^3.0.6",
39
+ "happy-dom": "^18.0.1",
40
+ "tsup": "^8.5.0",
41
+ "typescript": "^5.8.3",
42
+ "vite": "^7.0.0",
43
+ "vitest": "^3.2.4"
44
+ },
45
+ "engines": { "node": ">=20" },
46
+ "packageManager": "pnpm@10.15.0"
47
+ }