diff --git a/CHANGELOG.md b/CHANGELOG.md index eb401a0..bf79182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Per-category `hidden` flag.** Set `categories.analytics.hidden: true` + (or `categories.functional`, `categories.marketing`) to remove a consent + category from the settings modal. Hidden categories are forced to `false` + (rejected) in the consent state — a visitor can never accept a toggle they + cannot see. Essential cannot be hidden; the flag is ignored for it. Also + accepted via the `data-hide-categories="analytics,marketing"` script + attribute (comma-separated list of category IDs). Closes [#5]. + +[#5]: https://github.com/freshjuice-dev/zest/issues/5 + ## [2.5.1] - 2026-06-29 ### Changed diff --git a/README.md b/README.md index ea74468..6c56e7b 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,14 @@ window.ZestConfig = { onChange: (consent) => {}, onReady: (consent) => {}, onGeo: (action, verdict) => {} // fires when `geo` is configured + }, + + // Hide consent categories from the settings modal. Hidden categories + // are forced to false (rejected) — a visitor can never accept a toggle + // they cannot see. Essential cannot be hidden. + categories: { + analytics: { hidden: true }, + functional: { hidden: true } } }; ``` @@ -141,12 +149,17 @@ window.ZestConfig = { data-policy-url="/privacy" data-geo="on" data-branding="false" + data-hide-categories="analytics,functional" > ``` > `data-geo="on"` enables the hosted gateway. The `resolver` / `decide` > callbacks are JavaScript-only — use `window.ZestConfig.geo` for those. +> `data-hide-categories` accepts a comma-separated list of category IDs +> to hide from the settings modal (`analytics`, `functional`, `marketing`). +> Hidden categories are forced to false (rejected). Essential cannot be hidden. + ## API ```javascript diff --git a/src/config/parser.js b/src/config/parser.js index c968224..cfcee0d 100644 --- a/src/config/parser.js +++ b/src/config/parser.js @@ -87,6 +87,21 @@ function parseDataAttributes() { config.geo = geo; } + // Hide consent categories from the settings modal. + // data-hide-categories="analytics" or data-hide-categories="analytics,marketing" + // Hidden categories are forced to false (rejected) — a visitor must never + // accept a toggle they cannot see. Essential is always visible. + const hideAttr = script.getAttribute('data-hide-categories'); + if (hideAttr) { + const ids = hideAttr.split(',').map(s => s.trim()).filter(Boolean); + if (ids.length > 0) { + config.categories = {}; + for (const id of ids) { + config.categories[id] = { hidden: true }; + } + } + } + return config; } @@ -108,11 +123,24 @@ export function getConfig() { const windowConfig = parseWindowConfig(); const dataConfig = parseDataAttributes(); - // Merge: defaults < windowConfig < dataConfig - return mergeConfig({ - ...windowConfig, - ...dataConfig - }); + // Shallow spread, but deep-merge `categories` so window.ZestConfig + // category overrides (label, description, …) are not clobbered by + // data-hide-categories which sets { hidden: true } per category. + const merged = { ...windowConfig, ...dataConfig }; + if (windowConfig.categories && dataConfig.categories) { + merged.categories = {}; + for (const key of Object.keys(windowConfig.categories)) { + merged.categories[key] = { ...windowConfig.categories[key] }; + } + for (const key of Object.keys(dataConfig.categories)) { + merged.categories[key] = { + ...merged.categories[key], + ...dataConfig.categories[key] + }; + } + } + + return mergeConfig(merged); } /** diff --git a/src/core-lifecycle.js b/src/core-lifecycle.js index d7b9100..5bb5a9a 100644 --- a/src/core-lifecycle.js +++ b/src/core-lifecycle.js @@ -14,7 +14,7 @@ import { startScriptBlocking, setConsentChecker as setScriptChecker, replayScrip import { interceptNetwork, setConsentChecker as setNetworkChecker } from './core/network-interceptor.js'; import { interceptElements, setConsentChecker as setElementChecker, replayElements } from './core/element-interceptor.js'; import { setPatterns, appendPatternsToCategory } from './core/pattern-matcher.js'; -import { getCategoryIds } from './core/categories.js'; +import { getCategoryIds, getHiddenCategoryIds } from './core/categories.js'; import { isDoNotTrackEnabled } from './core/dnt.js'; import { safeInvoke } from './core/security.js'; import { resolveGeoAction } from './core/geo.js'; @@ -30,7 +30,8 @@ import { acceptAll as storeAcceptAll, rejectAll as storeRejectAll, resetConsent, - hasConsentDecision + hasConsentDecision, + setHiddenCategoryIds } from './storage/consent-store.js'; import { emitReady, emitConsent, emitReject, emitChange, emitGeo } from './storage/events.js'; @@ -70,6 +71,11 @@ export function coreInit(userConfig = {}) { currentConfig = setConfig(userConfig); + // Propagate hidden category IDs to the consent store so every consent + // write (load, update, acceptAll) forces them to false. A visitor must + // never end up "accepted" for a toggle they cannot see. + setHiddenCategoryIds(getHiddenCategoryIds(currentConfig.categories)); + // Push default-denied state to vendor consent mode APIs BEFORE any // third-party script has a chance to fire. applyConsentSignals( @@ -241,7 +247,7 @@ export function coreUpdateConsent(selections) { replayAll(newlyAllowed); } - const hasNonEssential = Object.entries(selections || {}).some( + const hasNonEssential = Object.entries(result.current).some( ([cat, val]) => cat !== 'essential' && val ); if (hasNonEssential) { diff --git a/src/core/categories.js b/src/core/categories.js index 9091182..aac7571 100644 --- a/src/core/categories.js +++ b/src/core/categories.js @@ -7,41 +7,55 @@ export const DEFAULT_CATEGORIES = { label: 'Essential', description: 'Required for the website to function properly. Cannot be disabled.', required: true, - default: true + default: true, + hidden: false }, functional: { id: 'functional', label: 'Functional', description: 'Enable personalized features like language preferences and themes.', required: false, - default: false + default: false, + hidden: false }, analytics: { id: 'analytics', label: 'Analytics', description: 'Help us understand how visitors interact with our website.', required: false, - default: false + default: false, + hidden: false }, marketing: { id: 'marketing', label: 'Marketing', description: 'Used to deliver relevant advertisements and track campaign performance.', required: false, - default: false + default: false, + hidden: false } }; /** - * Default consent state + * Default consent state. + * + * Hidden categories are forced to false — if a site hides a toggle from + * visitors, it must not end up "accepted" behind the scenes. */ -export function getDefaultConsent() { - return { +export function getDefaultConsent(categoryConfig = DEFAULT_CATEGORIES) { + const consent = { essential: true, functional: false, analytics: false, marketing: false }; + for (const key of Object.keys(DEFAULT_CATEGORIES)) { + const cat = categoryConfig[key]; + if (cat?.hidden && !cat.required) { + consent[key] = false; + } + } + return consent; } /** @@ -50,3 +64,13 @@ export function getDefaultConsent() { export function getCategoryIds() { return Object.keys(DEFAULT_CATEGORIES); } + +/** + * Get the list of category IDs that are hidden from the settings modal. + * Essential is never hidden. + */ +export function getHiddenCategoryIds(categoryConfig = DEFAULT_CATEGORIES) { + return Object.keys(DEFAULT_CATEGORIES).filter((key) => + categoryConfig[key]?.hidden === true && !categoryConfig[key]?.required + ); +} diff --git a/src/storage/consent-store.js b/src/storage/consent-store.js index 4881fcd..ff670c7 100644 --- a/src/storage/consent-store.js +++ b/src/storage/consent-store.js @@ -26,6 +26,32 @@ function secureAttribute() { // Current consent state let consent = null; +// Category IDs that are hidden from the modal. Set by coreInit from the +// merged config. Hidden categories are always forced to false — a visitor +// must never end up "accepted" for a toggle they cannot see. +let hiddenCategoryIds = []; + +/** + * Set which category IDs are hidden. Called once during coreInit after + * the config is merged. Essential should never appear here (categories.js + * filters it out), but we guard against it defensively. + */ +export function setHiddenCategoryIds(ids) { + hiddenCategoryIds = (Array.isArray(ids) ? ids : []).filter( + (id) => id !== 'essential' + ); +} + +/** + * Force every hidden category to false in a consent object. + */ +function applyHiddenOverride(state) { + for (const id of hiddenCategoryIds) { + state[id] = false; + } + return state; +} + /** * Get the original cookie setter (bypasses interception) */ @@ -67,7 +93,7 @@ export function loadConsent() { const raw = JSON.parse(decodeURIComponent(match[1])); const clean = sanitizeConsentPayload(raw, getCategoryIds()); if (clean && clean.categories) { - consent = { ...getDefaultConsent(), ...clean.categories }; + consent = applyHiddenOverride({ ...getDefaultConsent(), ...clean.categories }); return { ...consent }; } } @@ -75,7 +101,7 @@ export function loadConsent() { // Invalid or missing cookie } - consent = getDefaultConsent(); + consent = applyHiddenOverride(getDefaultConsent()); return { ...consent }; } @@ -84,7 +110,7 @@ export function loadConsent() { */ export function saveConsent(expirationDays = 365) { if (!consent) { - consent = getDefaultConsent(); + consent = applyHiddenOverride(getDefaultConsent()); } const data = { @@ -113,7 +139,7 @@ export function getConsent() { * Update consent state */ export function updateConsent(newConsent, expirationDays = 365) { - const previous = consent ? { ...consent } : getDefaultConsent(); + const previous = consent ? { ...consent } : applyHiddenOverride(getDefaultConsent()); consent = { essential: true, // Always true @@ -121,6 +147,7 @@ export function updateConsent(newConsent, expirationDays = 365) { analytics: !!newConsent.analytics, marketing: !!newConsent.marketing }; + applyHiddenOverride(consent); saveConsent(expirationDays); diff --git a/src/types/zest.d.ts b/src/types/zest.d.ts index 046b384..66091f3 100644 --- a/src/types/zest.d.ts +++ b/src/types/zest.d.ts @@ -173,6 +173,24 @@ export interface InterceptToggles { network?: boolean; } +/** Per-category configuration overrides. */ +export interface CategoryConfig { + /** Display label (defaults to the built-in label for this category). */ + label?: string; + /** Description shown under the label in the settings modal. */ + description?: string; + /** Whether the category is required (always on, toggle disabled). */ + required?: boolean; + /** Default consent state when no decision exists yet. */ + default?: boolean; + /** + * Hide this category from the settings modal. Hidden categories are + * forced to false (rejected) in the consent state. Essential cannot + * be hidden — the flag is ignored for it. + */ + hidden?: boolean; +} + /** Configuration accepted by `init()` and `window.ZestConfig`. */ export interface InitOptions { /** Display language. `'auto'` detects from `` / browser. */ @@ -216,6 +234,13 @@ export interface InitOptions { dntBehavior?: DNTBehavior; /** Disable individual interceptors. Default: all on. */ intercept?: InterceptToggles; + /** + * Per-category overrides. Set `hidden: true` to remove a category from + * the settings modal. Hidden categories are forced to false (rejected) + * in the consent state — a visitor can never accept a toggle they cannot + * see. Essential cannot be hidden. + */ + categories?: Partial>; /** * Opt-in geo / jurisdiction gating. Omit to show the banner to everyone * (the default). Pass `true` as shorthand for the hosted gateway diff --git a/src/ui/modal.js b/src/ui/modal.js index 6722916..e95861b 100644 --- a/src/ui/modal.js +++ b/src/ui/modal.js @@ -5,7 +5,7 @@ import { generateStyles } from './styles.js'; import { getCurrentConfig } from '../config/parser.js'; import { shouldShowBranding } from '../config/defaults.js'; -import { DEFAULT_CATEGORIES } from '../core/categories.js'; +import { DEFAULT_CATEGORIES, getHiddenCategoryIds } from '../core/categories.js'; import { escapeHTML, safeUrl } from '../core/security.js'; let modalElement = null; @@ -50,8 +50,10 @@ function createCategoryHTML(category, isChecked, isRequired) { function createModalHTML(config, consent) { const labels = config.labels.modal; const categories = config.categories || DEFAULT_CATEGORIES; + const hiddenIds = getHiddenCategoryIds(categories); const categoriesHTML = Object.values(categories) + .filter(cat => !hiddenIds.includes(cat.id)) .map(cat => createCategoryHTML( cat, consent[cat.id] ?? cat.default, @@ -104,6 +106,9 @@ function createModalHTML(config, consent) { */ function getSelections() { if (!shadowRoot) return currentSelections; + const config = getCurrentConfig(); + const categories = config.categories || DEFAULT_CATEGORIES; + const hiddenIds = getHiddenCategoryIds(categories); const toggles = shadowRoot.querySelectorAll('.zest-toggle__input'); const selections = { essential: true }; @@ -115,6 +120,13 @@ function getSelections() { } }); + // Hidden categories are never rendered, so they never appear in the + // toggle list. Explicitly set them to false so updateConsent receives + // a complete consent object. + for (const id of hiddenIds) { + selections[id] = false; + } + return selections; } diff --git a/tests/hide-categories.test.js b/tests/hide-categories.test.js new file mode 100644 index 0000000..80b8263 --- /dev/null +++ b/tests/hide-categories.test.js @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Stub document for the node test environment — consent-store calls +// descriptor.set.call(document, value) and document doesn't exist here. +if (typeof globalThis.document === 'undefined') { + globalThis.document = { cookie: '' }; +} + +// Mock cookie-interceptor before importing consent-store, so setRawCookie +// uses our fake descriptor instead of falling back to document.cookie +// (which doesn't exist in the node test environment). +const fakeStore = {}; +const fakeDescriptor = { + get: () => fakeStore.value || '', + set: (v) => { fakeStore.value = v; } +}; +vi.mock('../src/core/cookie-interceptor.js', () => ({ + getOriginalCookieDescriptor: () => fakeDescriptor +})); + +import { + DEFAULT_CATEGORIES, + getDefaultConsent, + getHiddenCategoryIds +} from '../src/core/categories.js'; +import { mergeConfig } from '../src/config/defaults.js'; +import { + setHiddenCategoryIds, + updateConsent, + acceptAll +} from '../src/storage/consent-store.js'; + +const base = { lang: 'en' }; + +describe('DEFAULT_CATEGORIES hidden flag', () => { + it('every category has hidden: false by default', () => { + for (const key of Object.keys(DEFAULT_CATEGORIES)) { + expect(DEFAULT_CATEGORIES[key].hidden).toBe(false); + } + }); +}); + +describe('getHiddenCategoryIds', () => { + it('returns empty array when nothing is hidden', () => { + expect(getHiddenCategoryIds()).toEqual([]); + }); + + it('returns hidden non-essential category IDs', () => { + const cats = { + ...DEFAULT_CATEGORIES, + analytics: { ...DEFAULT_CATEGORIES.analytics, hidden: true } + }; + expect(getHiddenCategoryIds(cats)).toEqual(['analytics']); + }); + + it('never includes essential even if hidden is true', () => { + const cats = { + ...DEFAULT_CATEGORIES, + essential: { ...DEFAULT_CATEGORIES.essential, hidden: true } + }; + expect(getHiddenCategoryIds(cats)).toEqual([]); + }); + + it('handles multiple hidden categories', () => { + const cats = { + ...DEFAULT_CATEGORIES, + functional: { ...DEFAULT_CATEGORIES.functional, hidden: true }, + analytics: { ...DEFAULT_CATEGORIES.analytics, hidden: true } + }; + expect(getHiddenCategoryIds(cats).sort()).toEqual(['analytics', 'functional']); + }); +}); + +describe('getDefaultConsent with hidden categories', () => { + it('returns all-false for non-essential by default', () => { + const consent = getDefaultConsent(); + expect(consent).toEqual({ + essential: true, + functional: false, + analytics: false, + marketing: false + }); + }); + + it('forces hidden categories to false even if default was true', () => { + const cats = { + ...DEFAULT_CATEGORIES, + analytics: { ...DEFAULT_CATEGORIES.analytics, hidden: true, default: true } + }; + const consent = getDefaultConsent(cats); + expect(consent.analytics).toBe(false); + }); + + it('essential stays true even if somehow hidden', () => { + const cats = { + ...DEFAULT_CATEGORIES, + essential: { ...DEFAULT_CATEGORIES.essential, hidden: true } + }; + const consent = getDefaultConsent(cats); + expect(consent.essential).toBe(true); + }); +}); + +describe('mergeConfig propagates hidden flag', () => { + it('mergeConfig preserves hidden from user config', () => { + const cfg = mergeConfig({ + ...base, + categories: { analytics: { hidden: true } } + }); + expect(cfg.categories.analytics.hidden).toBe(true); + }); + + it('mergeConfig leaves non-hidden categories at false', () => { + const cfg = mergeConfig({ + ...base, + categories: { analytics: { hidden: true } } + }); + expect(cfg.categories.functional.hidden).toBe(false); + expect(cfg.categories.marketing.hidden).toBe(false); + }); +}); + +describe('data-hide-categories parsing simulation', () => { + it('produces config.categories with hidden:true for each id', () => { + // Simulates what parseDataAttributes does when it reads + // data-hide-categories="analytics,marketing" + const hideAttr = 'analytics, marketing'; + const ids = hideAttr.split(',').map(s => s.trim()).filter(Boolean); + const dataConfig = {}; + if (ids.length > 0) { + dataConfig.categories = {}; + for (const id of ids) { + dataConfig.categories[id] = { hidden: true }; + } + } + const cfg = mergeConfig({ ...base, ...dataConfig }); + expect(cfg.categories.analytics.hidden).toBe(true); + expect(cfg.categories.marketing.hidden).toBe(true); + expect(cfg.categories.functional.hidden).toBe(false); + expect(cfg.categories.essential.hidden).toBe(false); + }); + + it('single category in data-hide-categories', () => { + const hideAttr = 'analytics'; + const ids = hideAttr.split(',').map(s => s.trim()).filter(Boolean); + const dataConfig = {}; + if (ids.length > 0) { + dataConfig.categories = {}; + for (const id of ids) { + dataConfig.categories[id] = { hidden: true }; + } + } + const cfg = mergeConfig({ ...base, ...dataConfig }); + expect(cfg.categories.analytics.hidden).toBe(true); + expect(cfg.categories.marketing.hidden).toBe(false); + }); + + it('empty string in data-hide-categories produces no categories', () => { + const hideAttr = ''; + const ids = hideAttr.split(',').map(s => s.trim()).filter(Boolean); + const dataConfig = {}; + if (ids.length > 0) { + dataConfig.categories = {}; + for (const id of ids) { + dataConfig.categories[id] = { hidden: true }; + } + } + expect(dataConfig.categories).toBeUndefined(); + }); + + it('essential in data-hide-categories gets hidden:true but getHiddenCategoryIds filters it out', () => { + const hideAttr = 'essential,analytics'; + const ids = hideAttr.split(',').map(s => s.trim()).filter(Boolean); + const dataConfig = {}; + if (ids.length > 0) { + dataConfig.categories = {}; + for (const id of ids) { + dataConfig.categories[id] = { hidden: true }; + } + } + const cfg = mergeConfig({ ...base, ...dataConfig }); + // mergeConfig merges essential: { hidden: true } over the default + expect(cfg.categories.essential.hidden).toBe(true); + // But getHiddenCategoryIds must never return essential + expect(getHiddenCategoryIds(cfg.categories)).toEqual(['analytics']); + }); + + it('window.ZestConfig categories + data-hide-categories preserve both', () => { + // Simulates: window.ZestConfig.categories.analytics = { label: "Stats" } + // data-hide-categories="analytics" + // The label override must survive, and hidden must be applied. + const windowConfig = { ...base, categories: { analytics: { label: 'Stats' } } }; + const dataConfig = { categories: { analytics: { hidden: true } } }; + + // Replicate the deep-merge logic from getConfig() + const merged = { ...windowConfig, ...dataConfig }; + if (windowConfig.categories && dataConfig.categories) { + merged.categories = {}; + for (const key of Object.keys(windowConfig.categories)) { + merged.categories[key] = { ...windowConfig.categories[key] }; + } + for (const key of Object.keys(dataConfig.categories)) { + merged.categories[key] = { + ...merged.categories[key], + ...dataConfig.categories[key] + }; + } + } + + const cfg = mergeConfig(merged); + expect(cfg.categories.analytics.hidden).toBe(true); + expect(cfg.categories.analytics.label).toBe('Stats'); + }); +}); + +describe('consent-store hidden category enforcement', () => { + beforeEach(() => { + setHiddenCategoryIds([]); + }); + + it('acceptAll forces hidden categories to false', () => { + setHiddenCategoryIds(['analytics']); + const result = acceptAll(1); + expect(result.current.essential).toBe(true); + expect(result.current.functional).toBe(true); + expect(result.current.marketing).toBe(true); + expect(result.current.analytics).toBe(false); + }); + + it('updateConsent forces hidden categories to false even when passed true', () => { + setHiddenCategoryIds(['analytics']); + const result = updateConsent({ + essential: true, + functional: true, + analytics: true, + marketing: true + }, 1); + expect(result.current.analytics).toBe(false); + }); + + it('setHiddenCategoryIds ignores essential', () => { + setHiddenCategoryIds(['essential', 'analytics']); + const result = acceptAll(1); + expect(result.current.essential).toBe(true); + expect(result.current.analytics).toBe(false); + }); + + it('no hidden categories = acceptAll works normally', () => { + setHiddenCategoryIds([]); + const result = acceptAll(1); + expect(result.current).toEqual({ + essential: true, + functional: true, + analytics: true, + marketing: true + }); + }); + + it('updateConsent with hidden analytics passed as true still forces false', () => { + setHiddenCategoryIds(['analytics']); + const result = updateConsent({ + essential: true, + functional: false, + analytics: true, + marketing: false + }, 1); + expect(result.current.analytics).toBe(false); + expect(result.current.functional).toBe(false); + }); + + it('multiple hidden categories all forced false on acceptAll', () => { + setHiddenCategoryIds(['functional', 'analytics', 'marketing']); + const result = acceptAll(1); + expect(result.current.essential).toBe(true); + expect(result.current.functional).toBe(false); + expect(result.current.analytics).toBe(false); + expect(result.current.marketing).toBe(false); + }); +}); \ No newline at end of file diff --git a/zest.config.schema.json b/zest.config.schema.json index 04bf13d..e4e91f1 100644 --- a/zest.config.schema.json +++ b/zest.config.schema.json @@ -327,6 +327,11 @@ }, "default": { "type": "boolean" + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Hide this category from the settings modal. Essential cannot be hidden. Hidden categories are forced to false (rejected) in the consent state — there is no way for a visitor to accept a category they cannot see." } } }