@websline/cms-view-utils 1.8.0 → 1.10.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@websline/cms-view-utils",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The path the ads endpoint targets against: no leading slash, no language
3
+ * segment — exactly what the page route takes. A browser knows the page as
4
+ * `/de/zimmer/suite`, the CMS as `zimmer/suite`, and the start page as `""`.
5
+ *
6
+ * Shared by the server-side middleware and the browser helper so the two cannot
7
+ * drift apart; a mismatch would silently aim page targeting at the wrong entity.
8
+ */
9
+ const toAdPath = (pathname, locale) => {
10
+ const segments = String(pathname ?? "")
11
+ .split("/")
12
+ .filter(Boolean);
13
+
14
+ if (locale && segments[0] === locale) segments.shift();
15
+
16
+ return segments.join("/");
17
+ };
18
+
19
+ export { toAdPath };
@@ -0,0 +1,252 @@
1
+ import { createDismissals } from "./dismissals.js";
2
+ import { createImpressions } from "./impressions.js";
3
+ import { filterEligibleAds } from "./selectAd.js";
4
+ import { groupIntoSlots, slotOf } from "./slots.js";
5
+ import { countPageView, watchTrigger } from "./triggers.js";
6
+ import { recordAdEvent } from "../client/recordAdEvent.js";
7
+
8
+ /**
9
+ * Runs a page's ads: who may appear, where, and when.
10
+ *
11
+ * Each slot — overlay, banner, each slide-in, the menu — holds at most one ad at
12
+ * a time, and the slots are independent of one another. Inside a slot every
13
+ * eligible candidate waits for its own trigger, and **the first to fire while the
14
+ * slot is free claims it**. A candidate whose trigger fires against an occupied
15
+ * slot is passed over silently; it was never shown, so it keeps its chance on the
16
+ * next page view.
17
+ *
18
+ * Because triggers are armed in priority order, two ads that fire at the same
19
+ * moment — two `immediate` overlays — resolve to the stronger one.
20
+ *
21
+ * Rendering stays with the template. So does saying when an ad actually became
22
+ * visible: `observe` is what turns a rendered ad into a counted view.
23
+ */
24
+
25
+ const SESSION_KEY = "wl_ads_session";
26
+
27
+ /** Half the ad in view is the point where a visitor can be said to have seen it. */
28
+ const VISIBLE_RATIO = 0.5;
29
+
30
+ const randomId = () =>
31
+ globalThis.crypto?.randomUUID?.() ??
32
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
33
+
34
+ /**
35
+ * One id per visit, so a click can be related to the view it came from. Kept in
36
+ * `sessionStorage`: it must not outlive the visit, and it is not a cookie because
37
+ * nothing on the server ever reads it back.
38
+ */
39
+ const resolveSessionId = (storage) => {
40
+ try {
41
+ const existing = storage?.getItem(SESSION_KEY);
42
+ if (existing) return existing;
43
+
44
+ const created = randomId();
45
+ storage?.setItem(SESSION_KEY, created);
46
+
47
+ return created;
48
+ } catch {
49
+ // No storage: the visit still counts, its events just cannot be grouped.
50
+ return randomId();
51
+ }
52
+ };
53
+
54
+ const parseCookies = (cookieString) =>
55
+ String(cookieString ?? "")
56
+ .split(";")
57
+ .reduce((cookies, part) => {
58
+ const index = part.indexOf("=");
59
+ if (index < 0) return cookies;
60
+
61
+ const name = part.slice(0, index).trim();
62
+ if (name) cookies[name] = decodeURIComponent(part.slice(index + 1).trim());
63
+
64
+ return cookies;
65
+ }, {});
66
+
67
+ /**
68
+ * @param {object} options
69
+ * @param {Record<string, object[]>} options.ads The endpoint's answer, from `locals.ads`
70
+ * @param {(ad: object) => void} options.onShow Render this ad now
71
+ * @param {() => boolean} [options.isConsentGiven] Defaults to "no consent"
72
+ * @param {boolean} [options.trackingEnabled] Off until a consent manager can allow it
73
+ */
74
+ const createAdRunner = ({
75
+ ads,
76
+ onShow,
77
+ isConsentGiven = () => false,
78
+ trackingEnabled = false,
79
+ env = {},
80
+ }) => {
81
+ const {
82
+ doc = typeof document === "undefined" ? null : document,
83
+ target = typeof window === "undefined" ? null : window,
84
+ storage,
85
+ sessionStore = typeof window === "undefined" ? null : window.sessionStorage,
86
+ now = Date.now(),
87
+ report = recordAdEvent,
88
+ observerFactory = defaultObserverFactory,
89
+ } = env;
90
+
91
+ const dismissals = createDismissals(storage);
92
+ const impressions = createImpressions(sessionStore);
93
+
94
+ // Entries for ads that are gone would otherwise pile up in every browser.
95
+ dismissals.prune(
96
+ Object.values(ads ?? {}).flatMap((list) => list.map(({ uuid }) => uuid)),
97
+ );
98
+
99
+ // Once for this page view, then handed to every trigger that needs it.
100
+ const pageViews = countPageView(sessionStore);
101
+
102
+ const sessionId = trackingEnabled ? resolveSessionId(sessionStore) : null;
103
+ const track = (uuid, type) => {
104
+ if (trackingEnabled) report({ uuid, type, sessionId });
105
+ };
106
+
107
+ /** Which ad currently holds each slot, and the teardowns to run on stop. */
108
+ const occupied = new Map();
109
+ const teardowns = [];
110
+ const counted = new Set();
111
+ const shown = [];
112
+
113
+ /**
114
+ * Takes the ad off the screen, frees its slot for a candidate that fires later,
115
+ * and starts its suppression — the same ending whether the visitor followed it
116
+ * or closed it.
117
+ */
118
+ const release = (ad) => {
119
+ const slot = slotOf(ad);
120
+ if (occupied.get(slot) === ad) occupied.delete(slot);
121
+
122
+ const index = shown.indexOf(ad);
123
+ if (index >= 0) shown.splice(index, 1);
124
+
125
+ dismissals.record(ad.uuid, Date.now());
126
+ };
127
+
128
+ const claim = (ad) => {
129
+ const slot = slotOf(ad);
130
+
131
+ // Someone got here first. Passed over rather than queued: it was never
132
+ // shown, so it may try again on the next page view.
133
+ if (occupied.has(slot)) return;
134
+
135
+ occupied.set(slot, ad);
136
+ shown.push(ad);
137
+ onShow(ad);
138
+ };
139
+
140
+ for (const [, candidates] of groupIntoSlots(ads)) {
141
+ const eligible = filterEligibleAds({
142
+ candidates,
143
+ now,
144
+ dismissedAt: (uuid) => dismissals.dismissedAt(uuid),
145
+ seenAt: (uuid) => impressions.seenAt(uuid),
146
+ cookies: parseCookies(doc?.cookie),
147
+ params: target ? new URLSearchParams(target.location.search) : undefined,
148
+ isConsentGiven,
149
+ });
150
+
151
+ for (const ad of eligible) {
152
+ teardowns.push(
153
+ watchTrigger(ad.trigger, () => claim(ad), {
154
+ doc,
155
+ target,
156
+ sessionStore,
157
+ pageViews,
158
+ }),
159
+ );
160
+ }
161
+ }
162
+
163
+ return {
164
+ /**
165
+ * The ads currently on screen, in the order they claimed their slot.
166
+ *
167
+ * A method, not a property: a framework that wraps this object in a deep
168
+ * reactive proxy would keep handing out the array as it looked when it was
169
+ * wrapped, and the runner mutates its own array from the outside. Calling
170
+ * in reads the truth every time.
171
+ */
172
+ shown: () => [...shown],
173
+
174
+ /**
175
+ * Counts the view once the element is really in front of the visitor. The
176
+ * template calls this after rendering; without it an ad is shown but never
177
+ * counted, which is the honest default — a number nobody produced should
178
+ * not be invented.
179
+ */
180
+ observe(ad, element) {
181
+ if (!ad || counted.has(ad.uuid)) return () => {};
182
+
183
+ const markSeen = () => {
184
+ if (counted.has(ad.uuid)) return;
185
+ counted.add(ad.uuid);
186
+
187
+ // Recorded whether or not anyone is measuring: how often a visitor is
188
+ // interrupted is not a statistic, it is the visitor's experience.
189
+ impressions.record(ad.uuid, Date.now());
190
+ track(ad.uuid, "view");
191
+ };
192
+
193
+ const observer = observerFactory(markSeen);
194
+
195
+ // No IntersectionObserver: the ad was rendered, and guessing it was seen
196
+ // is closer to the truth than dropping the view entirely.
197
+ if (!observer) {
198
+ markSeen();
199
+ return () => {};
200
+ }
201
+
202
+ observer.observe(element);
203
+ const stop = () => observer.disconnect();
204
+ teardowns.push(stop);
205
+
206
+ return stop;
207
+ },
208
+
209
+ /**
210
+ * Following the ad ends it too: a visitor who acted on it has seen what it
211
+ * had to say, and meeting it again on the next page reads as nagging. A
212
+ * persistent ad stays until the visitor closes it.
213
+ */
214
+ click(ad) {
215
+ if (!ad) return;
216
+
217
+ track(ad.uuid, "click");
218
+ if (!ad.persistent) release(ad);
219
+ },
220
+
221
+ /** An ad that is not closable has no close button, so nothing to dismiss. */
222
+ dismiss(ad) {
223
+ if (!ad || ad.closable === false) return;
224
+
225
+ track(ad.uuid, "close");
226
+ release(ad);
227
+ },
228
+
229
+ stop() {
230
+ for (const teardown of teardowns) teardown();
231
+ teardowns.length = 0;
232
+ },
233
+ };
234
+ };
235
+
236
+ const defaultObserverFactory = (onVisible) => {
237
+ if (typeof IntersectionObserver === "undefined") return null;
238
+
239
+ return new IntersectionObserver(
240
+ (entries, observer) => {
241
+ for (const entry of entries) {
242
+ if (!entry.isIntersecting) continue;
243
+
244
+ onVisible();
245
+ observer.disconnect();
246
+ }
247
+ },
248
+ { threshold: VISIBLE_RATIO },
249
+ );
250
+ };
251
+
252
+ export { createAdRunner, parseCookies, SESSION_KEY, VISIBLE_RATIO };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Remembers which ads a visitor closed, in `localStorage` and nowhere else: the
3
+ * value is only ever read in the browser, and as a cookie it would travel with
4
+ * every request without anyone looking at it.
5
+ *
6
+ * Stored is the **moment of the click**, never a computed expiry — the duration
7
+ * comes from the CMS response on every page view, so shortening it there reaches
8
+ * visitors who already closed the ad.
9
+ */
10
+
11
+ const STORAGE_KEY = "wl_ads_dismissed";
12
+
13
+ /**
14
+ * Every access is guarded: `localStorage` throws on a blocked origin and reads
15
+ * empty in a private window. A visitor who cannot store anything sees every ad
16
+ * again — annoying, but the page must not break over it.
17
+ */
18
+ const readAll = (storage) => {
19
+ try {
20
+ const raw = storage?.getItem(STORAGE_KEY);
21
+ const parsed = raw ? JSON.parse(raw) : null;
22
+
23
+ return parsed && typeof parsed === "object" ? parsed : {};
24
+ } catch {
25
+ return {};
26
+ }
27
+ };
28
+
29
+ const writeAll = (storage, entries) => {
30
+ try {
31
+ storage?.setItem(STORAGE_KEY, JSON.stringify(entries));
32
+ } catch {
33
+ // Storage full, blocked or unavailable — nothing to recover, and the only
34
+ // consequence is that this ad may show again.
35
+ }
36
+ };
37
+
38
+ const resolveStorage = (storage) => {
39
+ if (storage) return storage;
40
+
41
+ return typeof window === "undefined" ? null : window.localStorage;
42
+ };
43
+
44
+ const createDismissals = (storage) => {
45
+ const target = resolveStorage(storage);
46
+
47
+ return {
48
+ /** Epoch milliseconds of the visitor's click, or null. */
49
+ dismissedAt(uuid) {
50
+ const value = readAll(target)[uuid];
51
+
52
+ return typeof value === "number" ? value : null;
53
+ },
54
+
55
+ record(uuid, now = Date.now()) {
56
+ writeAll(target, { ...readAll(target), [uuid]: now });
57
+ },
58
+
59
+ /**
60
+ * Drops entries for ads the response no longer carries, so a site that ran
61
+ * campaigns for years does not grow an unbounded record in every browser.
62
+ */
63
+ prune(knownUuids) {
64
+ const keep = new Set(knownUuids);
65
+ const entries = readAll(target);
66
+ const pruned = Object.fromEntries(
67
+ Object.entries(entries).filter(([uuid]) => keep.has(uuid)),
68
+ );
69
+
70
+ if (Object.keys(pruned).length !== Object.keys(entries).length) {
71
+ writeAll(target, pruned);
72
+ }
73
+ },
74
+ };
75
+ };
76
+
77
+ export { createDismissals, STORAGE_KEY };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Remembers which ads this visit has already shown, so a reload does not put the
3
+ * same overlay back on the screen.
4
+ *
5
+ * Deliberately weaker than a dismissal and deliberately in `sessionStorage`:
6
+ * closing an ad is an explicit "no" and earns the full suppression the editor
7
+ * configured, while merely having seen one means "not now". It ends with the
8
+ * visit, so the message still reaches a visitor who comes back next week.
9
+ */
10
+
11
+ const STORAGE_KEY = "wl_ads_seen";
12
+
13
+ /** Keyed by uuid with the moment of the impression, so a reset can undo it. */
14
+ const readAll = (storage) => {
15
+ try {
16
+ const raw = storage?.getItem(STORAGE_KEY);
17
+ const parsed = raw ? JSON.parse(raw) : null;
18
+
19
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
20
+
21
+ return parsed;
22
+ } catch {
23
+ return {};
24
+ }
25
+ };
26
+
27
+ const resolveStorage = (storage) => {
28
+ if (storage) return storage;
29
+
30
+ return typeof window === "undefined" ? null : window.sessionStorage;
31
+ };
32
+
33
+ const createImpressions = (storage) => {
34
+ const target = resolveStorage(storage);
35
+
36
+ return {
37
+ /** Epoch milliseconds of the impression, or null. */
38
+ seenAt(uuid) {
39
+ const value = readAll(target)[uuid];
40
+
41
+ return typeof value === "number" ? value : null;
42
+ },
43
+
44
+ /**
45
+ * Records when the ad was seen *last*. Keeping the first moment would make
46
+ * an ad the editor just reset reappear on every page for the rest of the
47
+ * visit: its stored impression would stay older than the reset forever.
48
+ */
49
+ record(uuid, now = Date.now()) {
50
+ const seen = readAll(target);
51
+
52
+ try {
53
+ target?.setItem(STORAGE_KEY, JSON.stringify({ ...seen, [uuid]: now }));
54
+ } catch {
55
+ // Nothing to recover: the only consequence is that this ad may appear
56
+ // once more during the visit.
57
+ }
58
+ },
59
+ };
60
+ };
61
+
62
+ export { createImpressions, STORAGE_KEY };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Picks the ad a visitor actually gets to see. The CMS answers with every
3
+ * candidate that belongs on this page, in order; everything that describes the
4
+ * visitor rather than the page is decided here.
5
+ *
6
+ * Pure on purpose — the browser is passed in, never read. That keeps the rules
7
+ * testable and is why `document` and `localStorage` appear nowhere in this file.
8
+ */
9
+
10
+ const DAY_MS = 24 * 60 * 60 * 1000;
11
+
12
+ /** Matches the API's fallback, for an ad saved before the key existed. */
13
+ const DEFAULT_DISMISS_DURATION_DAYS = 7;
14
+
15
+ const parseTimestamp = (value) => {
16
+ if (!value) return null;
17
+
18
+ const parsed = Date.parse(value);
19
+
20
+ return Number.isNaN(parsed) ? null : parsed;
21
+ };
22
+
23
+ /**
24
+ * "Show it again" has to mean every reason the ad was being held back, not just
25
+ * one of them: anything the visitor did before the editor pressed the button is
26
+ * forgotten. Without this the reset works for a dismissal but not for an ad the
27
+ * visitor merely saw, and the button looks broken.
28
+ */
29
+ const survivesReset = (ad, at) => {
30
+ const resetAt = parseTimestamp(ad.conditions?.dismissResetAt);
31
+
32
+ return resetAt === null || at >= resetAt;
33
+ };
34
+
35
+ /**
36
+ * Whether the visitor closed this ad recently enough to still be rid of it.
37
+ *
38
+ * The duration is read from the response on every page view rather than baked
39
+ * into the stored entry, so shortening it in the CMS brings the ad back for
40
+ * everyone whose dismissal is already older.
41
+ */
42
+ const isSuppressed = (ad, dismissedAt, now) => {
43
+ if (!dismissedAt) return false;
44
+ if (!survivesReset(ad, dismissedAt)) return false;
45
+
46
+ const days = ad.conditions?.dismissDurationDays ?? DEFAULT_DISMISS_DURATION_DAYS;
47
+
48
+ return now < dismissedAt + days * DAY_MS;
49
+ };
50
+
51
+ /** Seen during this visit, and not before the editor reset it. */
52
+ const wasSeenThisVisit = (ad, seenAt) => Boolean(seenAt) && survivesReset(ad, seenAt);
53
+
54
+ /**
55
+ * A condition with no name set is no condition. With a name but no value the
56
+ * mere presence counts, which is how "only for visitors who have this cookie at
57
+ * all" is expressed.
58
+ */
59
+ const matchesNameValue = (name, value, actual) => {
60
+ if (!name) return true;
61
+ if (actual === undefined || actual === null) return false;
62
+
63
+ return value ? actual === value : true;
64
+ };
65
+
66
+ /**
67
+ * An ad the editor marked "only with confirmed consent" while nothing can confirm
68
+ * it stays hidden. The opposite reading would show exactly those ads that were
69
+ * singled out as needing permission.
70
+ */
71
+ const hasConsent = (ad, isConsentGiven) =>
72
+ ad.conditions?.requireConsent !== true || isConsentGiven() === true;
73
+
74
+ /**
75
+ * Every candidate this visitor may be shown, in the order they came in. More than
76
+ * one survives on purpose: they compete for a slot later, when their triggers
77
+ * fire, and the one that fires first while the slot is free wins it.
78
+ *
79
+ * @param {object} options
80
+ * @param {object[]} options.candidates Highest priority first
81
+ * @param {number} options.now Epoch milliseconds
82
+ * @param {(uuid: string) => number|null} options.dismissedAt When the visitor closed an ad
83
+ * @param {(uuid: string) => number|null} [options.seenAt] When it was shown during this visit
84
+ * @param {Record<string, string>} [options.cookies] Parsed cookies
85
+ * @param {URLSearchParams} [options.params] The current URL's query
86
+ * @param {() => boolean} [options.isConsentGiven] Defaults to "no consent"
87
+ * @returns {object[]} The eligible ads
88
+ */
89
+ const filterEligibleAds = ({
90
+ candidates = [],
91
+ now,
92
+ dismissedAt,
93
+ seenAt = () => null,
94
+ cookies = {},
95
+ params,
96
+ isConsentGiven = () => false,
97
+ }) =>
98
+ candidates.filter((ad) => {
99
+ if (!hasConsent(ad, isConsentGiven)) return false;
100
+ // Once per visit is the floor; a reload must not put it back on the screen.
101
+ // A persistent ad has no such floor: it stays on every page until closed.
102
+ if (!ad.persistent && wasSeenThisVisit(ad, seenAt(ad.uuid))) return false;
103
+ // `closable: false` has no close button, so no dismissal can hold it back.
104
+ if (ad.closable !== false && isSuppressed(ad, dismissedAt(ad.uuid), now)) {
105
+ return false;
106
+ }
107
+
108
+ const { cookieName, cookieValue, paramName, paramValue } = ad.conditions ?? {};
109
+
110
+ if (!matchesNameValue(cookieName, cookieValue, cookies[cookieName])) return false;
111
+
112
+ return matchesNameValue(
113
+ paramName,
114
+ paramValue,
115
+ paramName ? (params?.get(paramName) ?? undefined) : undefined,
116
+ );
117
+ });
118
+
119
+ export {
120
+ DEFAULT_DISMISS_DURATION_DAYS,
121
+ filterEligibleAds,
122
+ isSuppressed,
123
+ wasSeenThisVisit,
124
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Which ads can share the screen and which cannot.
3
+ *
4
+ * The unit is the **slot**, not the placement: a banner sits in the page flow at
5
+ * the top, an overlay is centred over everything, a slide-in clings to one edge.
6
+ * Those are different places, so they may run at the same time. Two overlays are
7
+ * the same place, so only one of them can.
8
+ */
9
+
10
+ /** A page ad without a layout renders as an overlay, like the wizard's default. */
11
+ const DEFAULT_PAGE_LAYOUT = "overlay";
12
+
13
+ const slotOf = (ad) =>
14
+ ad?.placement === "navigation"
15
+ ? "navigation"
16
+ : `page:${ad?.layout ?? DEFAULT_PAGE_LAYOUT}`;
17
+
18
+ /**
19
+ * Groups the endpoint's answer into slots, keeping the order inside each one —
20
+ * the API already sorted by priority, and that order decides who claims a free
21
+ * slot when two candidates fire at the same moment.
22
+ *
23
+ * @param {Record<string, object[]>} ads
24
+ * @returns {Map<string, object[]>}
25
+ */
26
+ const groupIntoSlots = (ads) => {
27
+ const slots = new Map();
28
+
29
+ for (const candidates of Object.values(ads ?? {})) {
30
+ for (const ad of candidates ?? []) {
31
+ const slot = slotOf(ad);
32
+
33
+ if (!slots.has(slot)) slots.set(slot, []);
34
+ slots.get(slot).push(ad);
35
+ }
36
+ }
37
+
38
+ return slots;
39
+ };
40
+
41
+ export { DEFAULT_PAGE_LAYOUT, groupIntoSlots, slotOf };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Waits for the single event that reveals an ad. Every watcher answers with its
3
+ * own teardown, because a slide-in that is still listening after the visitor
4
+ * navigated away would fire on a page it was never meant for.
5
+ *
6
+ * The browser pieces are injected rather than reached for, so the rules can be
7
+ * tested without a DOM.
8
+ */
9
+
10
+ const PAGE_VIEWS_KEY = "wl_ads_page_views";
11
+
12
+ /**
13
+ * Counted per session, not forever: "after three page views" describes one visit.
14
+ * Kept across visits it would fire immediately for every returning visitor.
15
+ */
16
+ /**
17
+ * Counted once per page view by the caller, not once per ad: two ads waiting for
18
+ * the same number would otherwise advance the counter twice, and a page carrying
19
+ * no such ad would not advance it at all — it would count eligible ads, not views.
20
+ */
21
+ const countPageView = (storage) => {
22
+ try {
23
+ const next = Number(storage?.getItem(PAGE_VIEWS_KEY) ?? 0) + 1;
24
+ storage?.setItem(PAGE_VIEWS_KEY, String(next));
25
+
26
+ return next;
27
+ } catch {
28
+ // Without storage every view is the first one, so a page-view trigger
29
+ // simply never fires rather than firing on every page.
30
+ return 1;
31
+ }
32
+ };
33
+
34
+ const never = () => () => {};
35
+
36
+ const afterDelay = (seconds, fire, timers) => {
37
+ const id = timers.setTimeout(fire, Math.max(0, seconds) * 1000);
38
+
39
+ return () => timers.clearTimeout(id);
40
+ };
41
+
42
+ /**
43
+ * How far down the visitor is, in percent of what the page can scroll. A page
44
+ * that fits the window cannot be scrolled and so never reaches any share.
45
+ */
46
+ const scrolledPercent = (target, doc) => {
47
+ const scrollable =
48
+ (doc?.documentElement?.scrollHeight ?? 0) - (target.innerHeight ?? 0);
49
+
50
+ return scrollable > 0 ? (target.scrollY / scrollable) * 100 : 0;
51
+ };
52
+
53
+ /**
54
+ * Fixed rather than set per ad: far enough to mean interest, early enough that
55
+ * most visitors get there. Not offered in the CMS, since no editor tunes it.
56
+ */
57
+ const SCROLL_SHARE_PERCENT = 30;
58
+
59
+ /**
60
+ * Keeps listening until the share is reached: `once` would drop the listener on
61
+ * an overscroll bounce back at the top and lose the ad for that visit.
62
+ */
63
+ const onScrolledPast = (fire, target, doc) => {
64
+ const reached = () =>
65
+ target.scrollY > 0 && scrolledPercent(target, doc) >= SCROLL_SHARE_PERCENT;
66
+
67
+ // Already that far when the ad arrived — no further event may come.
68
+ if (reached()) {
69
+ fire();
70
+ return () => {};
71
+ }
72
+
73
+ const stop = () => target.removeEventListener("scroll", handler);
74
+
75
+ function handler() {
76
+ if (!reached()) return;
77
+
78
+ stop();
79
+ fire();
80
+ }
81
+
82
+ target.addEventListener("scroll", handler, { passive: true });
83
+
84
+ return stop;
85
+ };
86
+
87
+ /**
88
+ * Leaving towards the browser chrome, which is the only exit a page can observe.
89
+ * Deliberately not `beforeunload`: that fires when it is already too late to show
90
+ * anything, and costs the back/forward cache.
91
+ */
92
+ const onExitIntent = (fire, doc) => {
93
+ const handler = (event) => {
94
+ // `relatedTarget` is empty only when the pointer left the document itself;
95
+ // without it, moving onto any element near the top edge fires the ad.
96
+ if (event.clientY <= 0 && !event.relatedTarget) fire();
97
+ };
98
+
99
+ doc.addEventListener("mouseout", handler);
100
+
101
+ return () => doc.removeEventListener("mouseout", handler);
102
+ };
103
+
104
+ /**
105
+ * @param {{kind: string, amount?: number}} trigger
106
+ * @param {() => void} onFire Called at most once
107
+ * @param {object} [env] Injected browser pieces
108
+ * @returns {() => void} Teardown
109
+ */
110
+ const watchTrigger = (trigger, onFire, env = {}) => {
111
+ const {
112
+ doc = typeof document === "undefined" ? null : document,
113
+ target = typeof window === "undefined" ? null : window,
114
+ sessionStore = typeof window === "undefined" ? null : window.sessionStorage,
115
+ // Wrapped, not handed over: pulling the browser's timer functions off the
116
+ // window and calling them as a method of a plain object throws "Illegal
117
+ // invocation". Node does not care, so only a real browser shows it.
118
+ timers = {
119
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
120
+ clearTimeout: (id) => clearTimeout(id),
121
+ },
122
+ pageViews,
123
+ } = env;
124
+
125
+ let fired = false;
126
+ const fire = () => {
127
+ if (fired) return;
128
+ fired = true;
129
+ onFire();
130
+ };
131
+
132
+ switch (trigger?.kind) {
133
+ case "immediate":
134
+ fire();
135
+ return never();
136
+
137
+ case "delay":
138
+ return afterDelay(trigger.amount ?? 0, fire, timers);
139
+
140
+ case "page_views": {
141
+ const views = pageViews ?? countPageView(sessionStore);
142
+ if (views >= (trigger.amount ?? 1)) fire();
143
+
144
+ return never();
145
+ }
146
+
147
+ case "scroll":
148
+ return target ? onScrolledPast(fire, target, doc) : never();
149
+
150
+ case "exit_intent":
151
+ return doc ? onExitIntent(fire, doc) : never();
152
+
153
+ default:
154
+ // An unknown trigger shows nothing rather than showing everything.
155
+ return never();
156
+ }
157
+ };
158
+
159
+ export { countPageView, PAGE_VIEWS_KEY, SCROLL_SHARE_PERCENT, watchTrigger };
@@ -0,0 +1,35 @@
1
+ import { toAdPath } from "../ads/adPath.js";
2
+
3
+ /**
4
+ * Fetch the ads that may be shown on the page currently being viewed.
5
+ *
6
+ * Answers one sorted candidate list per placement (`page`, `navigation`), both
7
+ * always present. Render the first candidate whose client-side conditions hold —
8
+ * consent, cookie, URL parameter and the suppression a visitor earned by closing
9
+ * it — and fall through to the next when one does not.
10
+ *
11
+ * @param {object} options
12
+ * @param {string} options.locale - Locale code, e.g. `"de"`.
13
+ * @param {string} [options.path] - Path of the page being viewed, without the leading slash and without the language segment, e.g. `"zimmer/doppelzimmer"`. Defaults to the current browser path; send `""` for the start page.
14
+ * @returns {Promise<Record<string, object[]>>} Candidates per placement, highest priority first.
15
+ */
16
+ const fetchAds = async ({ locale, path = currentPath(locale) }) => {
17
+ const params = new URLSearchParams();
18
+
19
+ if (path) {
20
+ params.set("path", path);
21
+ }
22
+
23
+ const query = params.toString();
24
+ const url = `/api/cms/api/public/ads/${locale}${query ? `?${query}` : ""}`;
25
+
26
+ const response = await fetch(url, { method: "GET" });
27
+
28
+ return response.json();
29
+ };
30
+
31
+ /** Outside a browser there is nothing to read; the caller passes the path itself. */
32
+ const currentPath = (locale) =>
33
+ typeof window === "undefined" ? "" : toAdPath(window.location.pathname, locale);
34
+
35
+ export { fetchAds };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Report that a visitor saw, clicked or closed one ad.
3
+ *
4
+ * Sent with `navigator.sendBeacon` where available: the `close` event fires while
5
+ * the page may already be going away, and a normal request would be cancelled
6
+ * with it. The endpoint answers `204`, so there is nothing to read either way,
7
+ * and a failed report never reaches the caller — a lost statistic must not take
8
+ * the page with it.
9
+ *
10
+ * @param {object} options
11
+ * @param {string} options.uuid - UUID of the ad, as delivered in the ad list.
12
+ * @param {"view"|"click"|"close"} options.type - What the visitor did. Send `view` once per ad that actually became visible, not per candidate the endpoint offered.
13
+ * @param {string} options.sessionId - Identifier for this visitor's session, 1 to 255 characters. Groups the events of one visit so a click can be related to the view it came from.
14
+ * @returns {void}
15
+ */
16
+ const recordAdEvent = ({ uuid, type, sessionId }) => {
17
+ const url = `/api/cms/api/public/ads/${uuid}/events`;
18
+ const body = JSON.stringify({ type, sessionId });
19
+
20
+ try {
21
+ if (navigator?.sendBeacon) {
22
+ navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
23
+ return;
24
+ }
25
+
26
+ fetch(url, {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body,
30
+ keepalive: true,
31
+ }).catch(() => {});
32
+ } catch {
33
+ // Nothing to recover and nobody to tell.
34
+ }
35
+ };
36
+
37
+ export { recordAdEvent };
@@ -0,0 +1,49 @@
1
+ import { toAdPath } from "../ads/adPath.js";
2
+ import { buildCmsAdsUrl } from "./urlResolver.js";
3
+ import { buildCmsHeaders } from "./headers.js";
4
+
5
+ /** What every caller gets when there is nothing to show, so no template has to guard. */
6
+ const EMPTY_ADS = { page: [], navigation: [] };
7
+
8
+ /**
9
+ * Loads the page's ads while the server renders it, rather than from the browser
10
+ * afterwards. The response is identical for every visitor of the same page, so
11
+ * there is nothing to personalise — and the banner layout sits in the page flow,
12
+ * where arriving late would shift the content that is already painted.
13
+ *
14
+ * Runs after the page fetch because only its answer names the locale and the
15
+ * resolved path; the ads endpoint answers from memory, so the added hop is small.
16
+ *
17
+ * **Never fails the page.** Ads are decoration: a CMS hiccup, a disabled module
18
+ * or a malformed answer leaves the page rendering without them.
19
+ *
20
+ * @param {import("astro").APIContext} context
21
+ * @param {object} page The page the CMS answered with
22
+ * @param {object} [options]
23
+ * @param {boolean} [options.skip] Answer empty without asking the CMS
24
+ */
25
+ const fetchAdsForPage = async (context, page, { skip = false } = {}) => {
26
+ const locale = page?.locale;
27
+
28
+ if (skip || !locale) return EMPTY_ADS;
29
+
30
+ try {
31
+ const response = await fetch(
32
+ buildCmsAdsUrl({ locale, path: toAdPath(page.path, locale) }),
33
+ { method: "GET", headers: buildCmsHeaders(context.request) },
34
+ );
35
+
36
+ if (!response.ok) return EMPTY_ADS;
37
+
38
+ const data = await response.json();
39
+
40
+ return {
41
+ page: Array.isArray(data?.page) ? data.page : [],
42
+ navigation: Array.isArray(data?.navigation) ? data.navigation : [],
43
+ };
44
+ } catch {
45
+ return EMPTY_ADS;
46
+ }
47
+ };
48
+
49
+ export { EMPTY_ADS, fetchAdsForPage };
@@ -1,3 +1,4 @@
1
+ import { fetchAdsForPage } from "./fetchAdsForPage.js";
1
2
  import { resolveDraftUuidFromToken } from "./editorToken.js";
2
3
  import { buildCmsPageUrl } from "./urlResolver.js";
3
4
  import { buildCmsHeaders } from "./headers.js";
@@ -42,6 +43,12 @@ const fetchPage = async (context) => {
42
43
  context.locals.languageSwitcher = data?.languageSwitcher ?? [];
43
44
  context.locals.tenant = data?.tenant ?? null;
44
45
  context.locals._cmsRaw = data;
46
+ // Never in the editor: an overlay covering the page an editor is working on
47
+ // would block the very content they are editing. The preview deliberately
48
+ // keeps them — that is where an editor checks what a visitor will get.
49
+ context.locals.ads = await fetchAdsForPage(context, context.locals.page, {
50
+ skip: Boolean(isCMSEditRoute),
51
+ });
45
52
 
46
53
  if (isCMSPreviewRoute) {
47
54
  context.locals._preview = true;
@@ -1,7 +1,10 @@
1
1
  import { buildCmsHeaders } from "./headers.js";
2
2
  import { HttpError } from "../shared/errors.js";
3
3
 
4
- const proxyToCms = async (context) => {
4
+ /** The events endpoint answers this, and a Response with a body on it throws. */
5
+ const NO_CONTENT = 204;
6
+
7
+ const proxyToCms = async (context, { allow } = {}) => {
5
8
  const cmsBase = import.meta.env.CMS_URL;
6
9
 
7
10
  if (!cmsBase) {
@@ -10,18 +13,39 @@ const proxyToCms = async (context) => {
10
13
 
11
14
  const { path = "" } = context.params;
12
15
  const { request } = context;
16
+
17
+ // A write forwards the site's CMS token, and that token may carry scopes far
18
+ // beyond page delivery. Writes therefore reach only the paths named by the
19
+ // route that opened them, and everything else looks like it is not there.
20
+ if (allow && !allow(path)) {
21
+ throw new HttpError(404, "Not Found");
22
+ }
13
23
  const url = new URL(request.url);
14
24
  const target = `${cmsBase}/${path}${url.search}`;
15
25
 
26
+ // A write needs its body and its content type carried over; a read has neither.
27
+ // What such a request may reach is decided by the CMS token's scopes, not here.
28
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
29
+
16
30
  const response = await fetch(target, {
17
31
  method: request.method,
18
- headers: buildCmsHeaders(request),
32
+ headers: hasBody
33
+ ? {
34
+ ...buildCmsHeaders(request),
35
+ "content-type": request.headers.get("content-type") ?? "application/json",
36
+ }
37
+ : buildCmsHeaders(request),
38
+ ...(hasBody ? { body: await request.text() } : {}),
19
39
  });
20
40
 
21
41
  if (!response.ok) {
22
42
  throw new HttpError(response.status, "CMS Error");
23
43
  }
24
44
 
45
+ if (response.status === NO_CONTENT) {
46
+ return new Response(null, { status: response.status });
47
+ }
48
+
25
49
  const data = await response.text();
26
50
 
27
51
  return new Response(data, {
@@ -32,8 +56,8 @@ const proxyToCms = async (context) => {
32
56
  });
33
57
  };
34
58
 
35
- const createCmsProxyHandler = () => {
36
- return (context) => proxyToCms(context);
59
+ const createCmsProxyHandler = ({ allow } = {}) => {
60
+ return (context) => proxyToCms(context, { allow });
37
61
  };
38
62
 
39
63
  export { proxyToCms, createCmsProxyHandler };
@@ -10,6 +10,12 @@ const buildCmsPageUrl = ({ draftUuid, path }) => {
10
10
  return `${CMS_BASE_URL}/api/public/pages${normalizedPath}`;
11
11
  };
12
12
 
13
+ const buildCmsAdsUrl = ({ locale, path }) => {
14
+ const query = path ? `?path=${encodeURIComponent(path)}` : "";
15
+
16
+ return `${CMS_BASE_URL}/api/public/ads/${locale}${query}`;
17
+ };
18
+
13
19
  const buildCmsSiteConfigUrl = () => {
14
20
  return `${CMS_BASE_URL}/api/public/site-config`;
15
21
  };
@@ -23,6 +29,7 @@ const buildCmsLlmsTxtUrl = () => {
23
29
  };
24
30
 
25
31
  export {
32
+ buildCmsAdsUrl,
26
33
  buildCmsPageUrl,
27
34
  buildCmsSiteConfigUrl,
28
35
  buildCmsSitemapUrl,
package/src/index.js CHANGED
@@ -7,6 +7,13 @@ export { fetchBoards } from "./client/fetchBoards.js";
7
7
  export { fetchBoard } from "./client/fetchBoard.js";
8
8
  export { fetchPageTeasers } from "./client/fetchPageTeasers.js";
9
9
  export { fetchRatings } from "./client/fetchRatings.js";
10
+ export { fetchAds } from "./client/fetchAds.js";
11
+
12
+ export { recordAdEvent } from "./client/recordAdEvent.js";
13
+
14
+ export { createAdRunner } from "./ads/createAdRunner.js";
15
+ export { slotOf } from "./ads/slots.js";
16
+ export { toAdPath } from "./ads/adPath.js";
10
17
 
11
18
  export { default as AddBlockPlaceholder } from "./components/AddBlockPlaceholder.svelte";
12
19
  export { default as BlockWrapper } from "./components/BlockWrapper.svelte";
@@ -2,4 +2,14 @@ import { createCmsProxyHandler } from "../cms/proxyToCms.js";
2
2
 
3
3
  export const prerender = false;
4
4
 
5
+ /** The only write a browser has any business making through this proxy. */
6
+ const AD_EVENT_PATH = /^api\/public\/ads\/[^/]+\/events$/;
7
+
5
8
  export const GET = createCmsProxyHandler();
9
+
10
+ // Ad events are reported from the browser, which can only reach the CMS through
11
+ // this proxy. Narrowed to that one path on purpose: the forwarded token may hold
12
+ // management scopes, and an open POST would hand them to every visitor.
13
+ export const POST = createCmsProxyHandler({
14
+ allow: (path) => AD_EVENT_PATH.test(path),
15
+ });