Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
};
```
Expand All @@ -141,12 +149,17 @@ window.ZestConfig = {
data-policy-url="/privacy"
data-geo="on"
data-branding="false"
data-hide-categories="analytics,functional"
></script>
```

> `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
Expand Down
38 changes: 33 additions & 5 deletions src/config/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
}

/**
Expand Down
12 changes: 9 additions & 3 deletions src/core-lifecycle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 31 additions & 7 deletions src/core/categories.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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
);
}
35 changes: 31 additions & 4 deletions src/storage/consent-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down Expand Up @@ -67,15 +93,15 @@ 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 };
}
}
} catch (e) {
// Invalid or missing cookie
}

consent = getDefaultConsent();
consent = applyHiddenOverride(getDefaultConsent());
return { ...consent };
}

Expand All @@ -84,7 +110,7 @@ export function loadConsent() {
*/
export function saveConsent(expirationDays = 365) {
if (!consent) {
consent = getDefaultConsent();
consent = applyHiddenOverride(getDefaultConsent());
}

const data = {
Expand Down Expand Up @@ -113,14 +139,15 @@ 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
functional: !!newConsent.functional,
analytics: !!newConsent.analytics,
marketing: !!newConsent.marketing
};
applyHiddenOverride(consent);

saveConsent(expirationDays);

Expand Down
25 changes: 25 additions & 0 deletions src/types/zest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html lang>` / browser. */
Expand Down Expand Up @@ -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<Record<ConsentCategory, CategoryConfig>>;
/**
* Opt-in geo / jurisdiction gating. Omit to show the banner to everyone
* (the default). Pass `true` as shorthand for the hosted gateway
Expand Down
14 changes: 13 additions & 1 deletion src/ui/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
Expand All @@ -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;
}

Expand Down
Loading