@websline/cms-view-utils 1.7.2 → 1.9.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 +47 -42
- package/src/ads/adPath.js +19 -0
- package/src/ads/createAdRunner.js +250 -0
- package/src/ads/dismissals.js +77 -0
- package/src/ads/impressions.js +62 -0
- package/src/ads/selectAd.js +120 -0
- package/src/ads/slots.js +41 -0
- package/src/ads/triggers.js +141 -0
- package/src/client/fetchAds.js +35 -0
- package/src/client/recordAdEvent.js +37 -0
- package/src/cms/fetchAdsForPage.js +49 -0
- package/src/cms/fetchPage.js +7 -0
- package/src/cms/proxyToCms.js +28 -4
- package/src/cms/urlResolver.js +7 -0
- package/src/index.js +7 -0
- package/src/integration/cmsRoutes.js +62 -0
- package/src/middleware/createCmsMiddleware.js +13 -1
- package/src/middleware/withRequestFilter.js +20 -14
- package/src/routes/cmsProxy.js +15 -0
- package/src/routes/llms.txt.js +5 -0
- package/src/routes/robots.txt.js +5 -0
- package/src/routes/sitemap.xml.js +5 -0
- package/src/server.js +1 -0
- package/src/shared/routes.js +7 -0
package/package.json
CHANGED
|
@@ -1,43 +1,48 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
2
|
+
"name": "@websline/cms-view-utils",
|
|
3
|
+
"version": "1.9.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"src",
|
|
7
|
+
"dist",
|
|
8
|
+
"!src/**/*.test.js"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./server": "./src/server.js",
|
|
13
|
+
"./integration": "./src/integration/cmsRoutes.js",
|
|
14
|
+
"./routes/*": "./src/routes/*",
|
|
15
|
+
"./components/*": "./src/components/*",
|
|
16
|
+
"./preview-bridge": "./dist/previewBridge.global.js",
|
|
17
|
+
"./edit-scroll": "./dist/editScroll.global.js",
|
|
18
|
+
"./dist/*": "./dist/*"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"jsonwebtoken": "^9.0.3",
|
|
22
|
+
"tailwind-merge": "^3.7.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@eslint/compat": "^2.1.1",
|
|
26
|
+
"@eslint/js": "^10.0.1",
|
|
27
|
+
"eslint": "^10.11.0",
|
|
28
|
+
"eslint-config-prettier": "^10.1.8",
|
|
29
|
+
"eslint-plugin-svelte": "^3.23.0",
|
|
30
|
+
"globals": "^17.12.0",
|
|
31
|
+
"prettier": "^3.9.8",
|
|
32
|
+
"prettier-plugin-svelte": "^4.1.1",
|
|
33
|
+
"prettier-plugin-tailwindcss": "^0.8.1",
|
|
34
|
+
"tsup": "^8.5.1",
|
|
35
|
+
"vitest": "^5.0.1"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"astro": ">=5",
|
|
39
|
+
"svelte": "^5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"sideEffects": false,
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup",
|
|
44
|
+
"format": "prettier --write .",
|
|
45
|
+
"lint": "prettier --check . && eslint .",
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -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,250 @@
|
|
|
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.
|
|
212
|
+
*/
|
|
213
|
+
click(ad) {
|
|
214
|
+
if (!ad) return;
|
|
215
|
+
|
|
216
|
+
track(ad.uuid, "click");
|
|
217
|
+
release(ad);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
dismiss(ad) {
|
|
221
|
+
if (!ad) return;
|
|
222
|
+
|
|
223
|
+
track(ad.uuid, "close");
|
|
224
|
+
release(ad);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
stop() {
|
|
228
|
+
for (const teardown of teardowns) teardown();
|
|
229
|
+
teardowns.length = 0;
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const defaultObserverFactory = (onVisible) => {
|
|
235
|
+
if (typeof IntersectionObserver === "undefined") return null;
|
|
236
|
+
|
|
237
|
+
return new IntersectionObserver(
|
|
238
|
+
(entries, observer) => {
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
if (!entry.isIntersecting) continue;
|
|
241
|
+
|
|
242
|
+
onVisible();
|
|
243
|
+
observer.disconnect();
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
{ threshold: VISIBLE_RATIO },
|
|
247
|
+
);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
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,120 @@
|
|
|
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
|
+
if (wasSeenThisVisit(ad, seenAt(ad.uuid))) return false;
|
|
102
|
+
if (isSuppressed(ad, dismissedAt(ad.uuid), now)) return false;
|
|
103
|
+
|
|
104
|
+
const { cookieName, cookieValue, paramName, paramValue } = ad.conditions ?? {};
|
|
105
|
+
|
|
106
|
+
if (!matchesNameValue(cookieName, cookieValue, cookies[cookieName])) return false;
|
|
107
|
+
|
|
108
|
+
return matchesNameValue(
|
|
109
|
+
paramName,
|
|
110
|
+
paramValue,
|
|
111
|
+
paramName ? (params?.get(paramName) ?? undefined) : undefined,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export {
|
|
116
|
+
DEFAULT_DISMISS_DURATION_DAYS,
|
|
117
|
+
filterEligibleAds,
|
|
118
|
+
isSuppressed,
|
|
119
|
+
wasSeenThisVisit,
|
|
120
|
+
};
|
package/src/ads/slots.js
ADDED
|
@@ -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,141 @@
|
|
|
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
|
+
* Keeps listening until the page has really moved. `once` would drop the listener
|
|
44
|
+
* on the first scroll event even when it arrived back at the top — an overscroll
|
|
45
|
+
* bounce or a sideways scroll — and the ad would never appear for that visit.
|
|
46
|
+
*/
|
|
47
|
+
const onFirstScroll = (fire, target) => {
|
|
48
|
+
const stop = () => target.removeEventListener("scroll", handler);
|
|
49
|
+
|
|
50
|
+
function handler() {
|
|
51
|
+
if (target.scrollY <= 0) return;
|
|
52
|
+
|
|
53
|
+
stop();
|
|
54
|
+
fire();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
target.addEventListener("scroll", handler, { passive: true });
|
|
58
|
+
|
|
59
|
+
return stop;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Leaving towards the browser chrome, which is the only exit a page can observe.
|
|
64
|
+
* Deliberately not `beforeunload`: that fires when it is already too late to show
|
|
65
|
+
* anything, and costs the back/forward cache.
|
|
66
|
+
*/
|
|
67
|
+
const onExitIntent = (fire, doc) => {
|
|
68
|
+
const handler = (event) => {
|
|
69
|
+
// `relatedTarget` is empty only when the pointer left the document itself;
|
|
70
|
+
// without it, moving onto any element near the top edge fires the ad.
|
|
71
|
+
if (event.clientY <= 0 && !event.relatedTarget) fire();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
doc.addEventListener("mouseout", handler);
|
|
75
|
+
|
|
76
|
+
return () => doc.removeEventListener("mouseout", handler);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {{kind: string, amount?: number}} trigger
|
|
81
|
+
* @param {() => void} onFire Called at most once
|
|
82
|
+
* @param {object} [env] Injected browser pieces
|
|
83
|
+
* @returns {() => void} Teardown
|
|
84
|
+
*/
|
|
85
|
+
const watchTrigger = (trigger, onFire, env = {}) => {
|
|
86
|
+
const {
|
|
87
|
+
doc = typeof document === "undefined" ? null : document,
|
|
88
|
+
target = typeof window === "undefined" ? null : window,
|
|
89
|
+
sessionStore = typeof window === "undefined" ? null : window.sessionStorage,
|
|
90
|
+
// Wrapped, not handed over: pulling the browser's timer functions off the
|
|
91
|
+
// window and calling them as a method of a plain object throws "Illegal
|
|
92
|
+
// invocation". Node does not care, so only a real browser shows it.
|
|
93
|
+
timers = {
|
|
94
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
95
|
+
clearTimeout: (id) => clearTimeout(id),
|
|
96
|
+
},
|
|
97
|
+
pageViews,
|
|
98
|
+
} = env;
|
|
99
|
+
|
|
100
|
+
let fired = false;
|
|
101
|
+
const fire = () => {
|
|
102
|
+
if (fired) return;
|
|
103
|
+
fired = true;
|
|
104
|
+
onFire();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
switch (trigger?.kind) {
|
|
108
|
+
case "immediate":
|
|
109
|
+
fire();
|
|
110
|
+
return never();
|
|
111
|
+
|
|
112
|
+
case "delay":
|
|
113
|
+
return afterDelay(trigger.amount ?? 0, fire, timers);
|
|
114
|
+
|
|
115
|
+
case "page_views": {
|
|
116
|
+
const views = pageViews ?? countPageView(sessionStore);
|
|
117
|
+
if (views >= (trigger.amount ?? 1)) fire();
|
|
118
|
+
|
|
119
|
+
return never();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case "scroll":
|
|
123
|
+
if (!target) return never();
|
|
124
|
+
// Already scrolled when the ad arrived — the event will not come again.
|
|
125
|
+
if (target.scrollY > 0) {
|
|
126
|
+
fire();
|
|
127
|
+
return never();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return onFirstScroll(fire, target);
|
|
131
|
+
|
|
132
|
+
case "exit_intent":
|
|
133
|
+
return doc ? onExitIntent(fire, doc) : never();
|
|
134
|
+
|
|
135
|
+
default:
|
|
136
|
+
// An unknown trigger shows nothing rather than showing everything.
|
|
137
|
+
return never();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export { countPageView, PAGE_VIEWS_KEY, 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 };
|
package/src/cms/fetchPage.js
CHANGED
|
@@ -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;
|
package/src/cms/proxyToCms.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { buildCmsHeaders } from "./headers.js";
|
|
2
2
|
import { HttpError } from "../shared/errors.js";
|
|
3
3
|
|
|
4
|
-
|
|
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:
|
|
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 };
|
package/src/cms/urlResolver.js
CHANGED
|
@@ -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";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { CMS_ROUTE_PATTERN } from "../shared/routes.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Astro integration that ships the CMS-backed routes every website needs, so a
|
|
5
|
+
* site repository does not have to re-declare them. A project file of the same
|
|
6
|
+
* pattern wins over the injected route; `exclude` makes that intent explicit.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const INJECTED_ROUTES = {
|
|
10
|
+
"/robots.txt": "@websline/cms-view-utils/routes/robots.txt.js",
|
|
11
|
+
"/sitemap.xml": "@websline/cms-view-utils/routes/sitemap.xml.js",
|
|
12
|
+
"/llms.txt": "@websline/cms-view-utils/routes/llms.txt.js",
|
|
13
|
+
"/api/cms/[...path]": "@websline/cms-view-utils/routes/cmsProxy.js",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {string[]} [options.exclude] - route patterns the project provides itself
|
|
19
|
+
* @param {string} [options.cmsRoutePattern] - route pattern of the CMS catch-all page
|
|
20
|
+
*/
|
|
21
|
+
const cmsRoutes = (options = {}) => {
|
|
22
|
+
const { exclude = [], cmsRoutePattern = CMS_ROUTE_PATTERN } = options;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
name: "@websline/cms-view-utils",
|
|
26
|
+
hooks: {
|
|
27
|
+
"astro:config:setup": ({ injectRoute }) => {
|
|
28
|
+
for (const [pattern, entrypoint] of Object.entries(INJECTED_ROUTES)) {
|
|
29
|
+
if (exclude.includes(pattern)) continue;
|
|
30
|
+
|
|
31
|
+
injectRoute({ pattern, entrypoint });
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"astro:routes:resolved": ({ routes, logger }) => {
|
|
35
|
+
const hasCmsRoute = routes.some((route) => route.pattern === cmsRoutePattern);
|
|
36
|
+
|
|
37
|
+
if (!hasCmsRoute) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`@websline/cms-view-utils: no route matches "${cmsRoutePattern}". ` +
|
|
40
|
+
"The CMS middleware only runs for that route, so every page would " +
|
|
41
|
+
"render without CMS data. Add src/pages/[...path].astro or pass " +
|
|
42
|
+
"cmsRoutePattern to cmsRoutes() and createCmsMiddleware().",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const pattern of Object.keys(INJECTED_ROUTES)) {
|
|
47
|
+
const origins = routes
|
|
48
|
+
.filter((route) => route.pattern === pattern)
|
|
49
|
+
.map((route) => route.origin);
|
|
50
|
+
|
|
51
|
+
if (origins.includes("project") && origins.includes("external")) {
|
|
52
|
+
logger.info(
|
|
53
|
+
`Project route "${pattern}" overrides the injected one. Pass it in cmsRoutes({ exclude }) to make that explicit.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export { cmsRoutes };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { withRequestFilter } from "./withRequestFilter.js";
|
|
2
2
|
import { withCmsFetch } from "./withCmsFetch.js";
|
|
3
|
+
import { CMS_ROUTE_PATTERN } from "../shared/routes.js";
|
|
4
|
+
import { HttpError } from "../shared/errors.js";
|
|
3
5
|
|
|
4
6
|
const defaultErrorHandler = (context, error, wrap) => {
|
|
5
7
|
const status = error?.status ?? 500;
|
|
@@ -11,7 +13,12 @@ const defaultErrorHandler = (context, error, wrap) => {
|
|
|
11
13
|
/**
|
|
12
14
|
* Composes the standard CMS middleware pipeline.
|
|
13
15
|
*
|
|
16
|
+
* The CMS catch-all route renders CMS data and nothing else, so a request that
|
|
17
|
+
* reaches it without running the pipeline is answered 404 instead of rendering
|
|
18
|
+
* an empty page.
|
|
19
|
+
*
|
|
14
20
|
* @param {object} [options]
|
|
21
|
+
* @param {string} [options.cmsRoutePattern] - route pattern of the CMS catch-all page
|
|
15
22
|
* @param {(context: import("astro").APIContext) => boolean} [options.shouldSkip] - filter that decides if request bypasses the pipeline
|
|
16
23
|
* @param {(context: import("astro").APIContext) => Promise<void>} [options.fetch] - CMS fetch step
|
|
17
24
|
* @param {(context: import("astro").APIContext, handler: () => any) => Promise<Response>} [options.paraglide] - i18n wrapper (optional)
|
|
@@ -21,7 +28,8 @@ const defaultErrorHandler = (context, error, wrap) => {
|
|
|
21
28
|
*/
|
|
22
29
|
const createCmsMiddleware = (options = {}) => {
|
|
23
30
|
const {
|
|
24
|
-
|
|
31
|
+
cmsRoutePattern = CMS_ROUTE_PATTERN,
|
|
32
|
+
shouldSkip = withRequestFilter({ cmsRoutePattern }),
|
|
25
33
|
fetch = withCmsFetch(),
|
|
26
34
|
paraglide,
|
|
27
35
|
beforeFetch,
|
|
@@ -33,6 +41,10 @@ const createCmsMiddleware = (options = {}) => {
|
|
|
33
41
|
|
|
34
42
|
return async (context, next) => {
|
|
35
43
|
if (shouldSkip(context)) {
|
|
44
|
+
if (context.routePattern === cmsRoutePattern) {
|
|
45
|
+
return onError(context, new HttpError(404, "Not Found"), wrap);
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
return next();
|
|
37
49
|
}
|
|
38
50
|
|
|
@@ -1,33 +1,39 @@
|
|
|
1
|
+
import { CMS_ROUTE_PATTERN } from "../shared/routes.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Default request filter —
|
|
3
|
-
*
|
|
4
|
+
* Default request filter — the CMS pipeline runs for the catch-all route only.
|
|
5
|
+
*
|
|
6
|
+
* Every other route Astro matched is its own (a project page, an endpoint, an
|
|
7
|
+
* injected route) and must not be fed CMS data. Static files never get here:
|
|
8
|
+
* Astro drops them before route matching, and the server in front serves them.
|
|
9
|
+
*
|
|
10
|
+
* Requires `context.routePattern` (Astro 5 or newer).
|
|
4
11
|
*
|
|
5
12
|
* @param {object} [options]
|
|
13
|
+
* @param {string} [options.cmsRoutePattern] - route pattern of the CMS catch-all page
|
|
6
14
|
* @param {string[]} [options.additionalExcludePaths] - extra path prefixes to skip
|
|
7
15
|
* @param {(context: import("astro").APIContext) => boolean} [options.customFilter] - additional skip predicate
|
|
8
16
|
*/
|
|
9
|
-
|
|
10
17
|
const withRequestFilter = (options = {}) => {
|
|
11
|
-
const {
|
|
18
|
+
const {
|
|
19
|
+
cmsRoutePattern = CMS_ROUTE_PATTERN,
|
|
20
|
+
additionalExcludePaths = [],
|
|
21
|
+
customFilter,
|
|
22
|
+
} = options;
|
|
12
23
|
|
|
13
24
|
return (context) => {
|
|
14
|
-
const { request, url } = context;
|
|
25
|
+
const { request, url, routePattern } = context;
|
|
15
26
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const hasFileExtension = /\.[a-zA-Z0-9]+$/.test(url.pathname);
|
|
27
|
+
const isNonGet = request.method !== "GET";
|
|
28
|
+
const isOwnAstroRoute = routePattern !== cmsRoutePattern;
|
|
19
29
|
const isExcludedPath = additionalExcludePaths.some((p) =>
|
|
20
30
|
url.pathname.startsWith(p),
|
|
21
31
|
);
|
|
22
|
-
const isAstroApiCall = url.pathname.startsWith("/api/");
|
|
23
32
|
|
|
24
33
|
return (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
isErrorRoute ||
|
|
28
|
-
hasFileExtension ||
|
|
34
|
+
isNonGet ||
|
|
35
|
+
isOwnAstroRoute ||
|
|
29
36
|
isExcludedPath ||
|
|
30
|
-
isAstroApiCall ||
|
|
31
37
|
customFilter?.(context) === true
|
|
32
38
|
);
|
|
33
39
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createCmsProxyHandler } from "../cms/proxyToCms.js";
|
|
2
|
+
|
|
3
|
+
export const prerender = false;
|
|
4
|
+
|
|
5
|
+
/** The only write a browser has any business making through this proxy. */
|
|
6
|
+
const AD_EVENT_PATH = /^api\/public\/ads\/[^/]+\/events$/;
|
|
7
|
+
|
|
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
|
+
});
|
package/src/server.js
CHANGED
|
@@ -8,3 +8,4 @@ export { createSitemapHandler } from "./handlers/sitemapHandler.js";
|
|
|
8
8
|
export { createRobotsHandler } from "./handlers/robotsHandler.js";
|
|
9
9
|
export { createLlmsTxtHandler } from "./handlers/llmsTxtHandler.js";
|
|
10
10
|
export { createCmsProxyHandler } from "./cms/proxyToCms.js";
|
|
11
|
+
export { CMS_ROUTE_PATTERN } from "./shared/routes.js";
|