@astronox/core 0.3.1 → 0.4.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/README.md +2 -2
- package/package.json +1 -1
- package/src/ads/adsense/AdsenseHead.astro +55 -0
- package/src/ads/adsense/AdsenseUnit.astro +45 -0
- package/src/ads/adsense/index.ts +3 -1
- package/src/ads/adsense/provider.ts +41 -0
- package/src/ads/adsense/runtime.ts +284 -0
- package/src/ads/contracts.ts +67 -5
- package/src/components/monetization/AdSlot/AdSlot.astro +0 -3
- package/src/components/monetization/AdSlot/AdSlot.css +5 -28
- package/src/components/navigation/Footer/Footer.astro +8 -0
- package/src/components/navigation/Footer/Footer.css +20 -6
- package/src/components/navigation/Footer/contracts.ts +1 -0
- package/src/composition/SurfaceGroup/SurfaceGroup.astro +2 -1
- package/src/config/environment.ts +66 -2
- package/src/layouts/BaseLayout.astro +6 -1
- package/src/ads/adsense.ts +0 -112
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ component labels can extend `defaultCoreMessages` through
|
|
|
63
63
|
claims, Firestore, and app-owned mappings.
|
|
64
64
|
- [`R2 publication`](docs/integrations/r2-publication.md): full provider
|
|
65
65
|
snapshots and build-time public projection.
|
|
66
|
-
- [`AdSense`](docs/integrations/adsense.md):
|
|
67
|
-
|
|
66
|
+
- [`AdSense`](docs/integrations/adsense.md): typed configuration, responsive
|
|
67
|
+
units, host policy, Privacy & Messaging, and deployment.
|
|
68
68
|
- [`release workflow`](docs/release-workflow.md): local links, verification, and
|
|
69
69
|
consumer pinning order.
|
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { AdsenseAdsConfig } from '../contracts';
|
|
3
|
+
import {
|
|
4
|
+
renderAdsenseControlledMessagingScript,
|
|
5
|
+
renderAdsenseErrorProtectionScript,
|
|
6
|
+
toAdsenseClientId,
|
|
7
|
+
} from './provider';
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
config: AdsenseAdsConfig;
|
|
11
|
+
offerwallEligible: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const { config, offerwallEligible }: Props = Astro.props;
|
|
15
|
+
const clientId = toAdsenseClientId(config.publisherId);
|
|
16
|
+
const runtimeConfig = JSON.stringify({
|
|
17
|
+
allowedHosts: config.allowedHosts,
|
|
18
|
+
clientId,
|
|
19
|
+
messaging: {
|
|
20
|
+
adBlockingRecovery: config.messaging.adBlockingRecovery,
|
|
21
|
+
offerwall: config.messaging.offerwall,
|
|
22
|
+
offerwallEligible,
|
|
23
|
+
},
|
|
24
|
+
publisherId: config.publisherId,
|
|
25
|
+
}).replaceAll('<', '\\u003c');
|
|
26
|
+
const errorProtectionScript = config.messaging.errorProtectionCode
|
|
27
|
+
? renderAdsenseErrorProtectionScript({
|
|
28
|
+
allowedHosts: config.allowedHosts,
|
|
29
|
+
code: config.messaging.errorProtectionCode,
|
|
30
|
+
})
|
|
31
|
+
: undefined;
|
|
32
|
+
const controlledMessagingScript = renderAdsenseControlledMessagingScript(
|
|
33
|
+
config.messaging.offerwall && offerwallEligible,
|
|
34
|
+
);
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<meta name="google-adsense-account" content={clientId} />
|
|
38
|
+
<script
|
|
39
|
+
is:inline
|
|
40
|
+
type="application/json"
|
|
41
|
+
data-astronox-adsense-config
|
|
42
|
+
set:html={runtimeConfig}
|
|
43
|
+
/>
|
|
44
|
+
<script is:inline set:html={controlledMessagingScript} />
|
|
45
|
+
{
|
|
46
|
+
errorProtectionScript ? (
|
|
47
|
+
<script is:inline set:html={errorProtectionScript} />
|
|
48
|
+
) : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
<script>
|
|
52
|
+
import { initAdsenseFromDocument } from './runtime';
|
|
53
|
+
|
|
54
|
+
initAdsenseFromDocument();
|
|
55
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
import AdSlot from '../../components/monetization/AdSlot/AdSlot.astro';
|
|
4
|
+
import { toAdsenseClientId } from './provider';
|
|
5
|
+
|
|
6
|
+
type Props = Omit<HTMLAttributes<'aside'>, 'aria-label'> & {
|
|
7
|
+
format: 'display' | 'multiplex';
|
|
8
|
+
label: string;
|
|
9
|
+
publisherId: string;
|
|
10
|
+
size?: 'compact' | 'standard' | 'tall';
|
|
11
|
+
slotId: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
format,
|
|
16
|
+
label,
|
|
17
|
+
publisherId,
|
|
18
|
+
size = 'standard',
|
|
19
|
+
slotId,
|
|
20
|
+
...attrs
|
|
21
|
+
}: Props = Astro.props;
|
|
22
|
+
const unitAttributes =
|
|
23
|
+
format === 'multiplex'
|
|
24
|
+
? { 'data-ad-format': 'autorelaxed' }
|
|
25
|
+
: {
|
|
26
|
+
'data-ad-format': 'auto',
|
|
27
|
+
'data-full-width-responsive': 'true',
|
|
28
|
+
};
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<AdSlot
|
|
32
|
+
{...attrs}
|
|
33
|
+
data-adsense-format={format}
|
|
34
|
+
data-adsense-unit
|
|
35
|
+
label={label}
|
|
36
|
+
size={size}
|
|
37
|
+
state="loading"
|
|
38
|
+
>
|
|
39
|
+
<ins
|
|
40
|
+
{...unitAttributes}
|
|
41
|
+
class="adsbygoogle"
|
|
42
|
+
style="display:block"
|
|
43
|
+
data-ad-client={toAdsenseClientId(publisherId)}
|
|
44
|
+
data-ad-slot={slotId}></ins>
|
|
45
|
+
</AdSlot>
|
package/src/ads/adsense/index.ts
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const adsenseSellerId = 'f08c47fec0942fa0';
|
|
2
|
+
|
|
3
|
+
export function toAdsenseClientId(publisherId: string): string {
|
|
4
|
+
return `ca-${publisherId}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function renderAdsenseAdsTxt(publisherId: string): string {
|
|
8
|
+
return `google.com, ${publisherId}, DIRECT, ${adsenseSellerId}\n`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function renderAdsenseErrorProtectionScript({
|
|
12
|
+
allowedHosts,
|
|
13
|
+
code,
|
|
14
|
+
}: {
|
|
15
|
+
allowedHosts: readonly string[];
|
|
16
|
+
code: string;
|
|
17
|
+
}): string {
|
|
18
|
+
const hosts = JSON.stringify(
|
|
19
|
+
allowedHosts.map((host) => host.toLowerCase()),
|
|
20
|
+
).replaceAll('<', '\\u003c');
|
|
21
|
+
|
|
22
|
+
return `if (${hosts}.includes(window.location.hostname.toLowerCase())) {\n${code}\n}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderAdsenseControlledMessagingScript(
|
|
26
|
+
offerwallAllowed: boolean,
|
|
27
|
+
): string {
|
|
28
|
+
return `window.googlefc = window.googlefc || {};
|
|
29
|
+
window.googlefc.controlledMessagingFunction = function (message) {
|
|
30
|
+
if (${String(offerwallAllowed)}) {
|
|
31
|
+
message.proceed(true);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
var offerwall = window.googlefc.MessageTypeEnum && window.googlefc.MessageTypeEnum.OFFERWALL;
|
|
35
|
+
if (typeof offerwall === 'number') {
|
|
36
|
+
message.proceed(false, [offerwall]);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
message.proceed(true);
|
|
40
|
+
};`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { setAdSlotState } from '../slot';
|
|
2
|
+
|
|
3
|
+
const adsenseScriptTimeoutMs = 12_000;
|
|
4
|
+
const adsenseUnitTimeoutMs = 30_000;
|
|
5
|
+
|
|
6
|
+
type ControlledMessage = {
|
|
7
|
+
proceed(allowed: boolean, messageTypes?: readonly number[]): void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type GoogleFundingChoicesCallback =
|
|
11
|
+
(() => void) | { CONSENT_API_READY: () => void };
|
|
12
|
+
|
|
13
|
+
type GoogleFundingChoices = {
|
|
14
|
+
callbackQueue?: {
|
|
15
|
+
push(callback: GoogleFundingChoicesCallback): number;
|
|
16
|
+
};
|
|
17
|
+
controlledMessagingFunction?: (message: ControlledMessage) => void;
|
|
18
|
+
MessageTypeEnum?: {
|
|
19
|
+
OFFERWALL: number;
|
|
20
|
+
};
|
|
21
|
+
showRevocationMessage?: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type AdsenseRuntimeConfig = {
|
|
25
|
+
allowedHosts: readonly string[];
|
|
26
|
+
clientId: string;
|
|
27
|
+
messaging: {
|
|
28
|
+
adBlockingRecovery: boolean;
|
|
29
|
+
offerwall: boolean;
|
|
30
|
+
offerwallEligible: boolean;
|
|
31
|
+
};
|
|
32
|
+
publisherId: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
declare global {
|
|
36
|
+
interface Window {
|
|
37
|
+
adsbygoogle?: Record<string, unknown>[];
|
|
38
|
+
googlefc?: GoogleFundingChoices;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let adsenseScript: Promise<void> | undefined;
|
|
43
|
+
|
|
44
|
+
export function initAdsenseFromDocument(): void {
|
|
45
|
+
const config = readRuntimeConfig();
|
|
46
|
+
if (!config || !isAllowedHost(config.allowedHosts)) {
|
|
47
|
+
setAllAdUnitsState('unavailable');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protectAdAnnotations();
|
|
52
|
+
configureControlledMessaging(config);
|
|
53
|
+
initPrivacySettingsControls();
|
|
54
|
+
if (config.messaging.adBlockingRecovery) {
|
|
55
|
+
loadAdBlockingRecovery(config.publisherId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
void loadAdsenseScript(config.clientId)
|
|
59
|
+
.then(initAdUnits)
|
|
60
|
+
.catch(() => setAllAdUnitsState('blocked'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readRuntimeConfig(): AdsenseRuntimeConfig | undefined {
|
|
64
|
+
const element = document.querySelector<HTMLScriptElement>(
|
|
65
|
+
'script[data-astronox-adsense-config]',
|
|
66
|
+
);
|
|
67
|
+
if (!element?.textContent) return undefined;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(element.textContent) as AdsenseRuntimeConfig;
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isAllowedHost(allowedHosts: readonly string[]): boolean {
|
|
77
|
+
const hostname = window.location.hostname.toLowerCase();
|
|
78
|
+
if (
|
|
79
|
+
hostname === 'localhost' ||
|
|
80
|
+
hostname.endsWith('.localhost') ||
|
|
81
|
+
hostname === '127.0.0.1' ||
|
|
82
|
+
hostname === '::1'
|
|
83
|
+
) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return allowedHosts.some((host) => host.toLowerCase() === hostname);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function protectAdAnnotations(): void {
|
|
90
|
+
const protect = (root: ParentNode): void => {
|
|
91
|
+
if (
|
|
92
|
+
root instanceof HTMLElement &&
|
|
93
|
+
root.dataset.adAnnotation === 'exclude'
|
|
94
|
+
) {
|
|
95
|
+
root.classList.add('google-anno-skip');
|
|
96
|
+
}
|
|
97
|
+
for (const element of root.querySelectorAll<HTMLElement>(
|
|
98
|
+
'[data-ad-annotation="exclude"]',
|
|
99
|
+
)) {
|
|
100
|
+
element.classList.add('google-anno-skip');
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
protect(document);
|
|
105
|
+
new MutationObserver((records) => {
|
|
106
|
+
for (const record of records) {
|
|
107
|
+
for (const node of record.addedNodes) {
|
|
108
|
+
if (node instanceof HTMLElement) protect(node);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function configureControlledMessaging(config: AdsenseRuntimeConfig): void {
|
|
115
|
+
const googlefc = (window.googlefc ??= {});
|
|
116
|
+
googlefc.controlledMessagingFunction = (message) => {
|
|
117
|
+
const allowOfferwall =
|
|
118
|
+
config.messaging.offerwall && config.messaging.offerwallEligible;
|
|
119
|
+
if (allowOfferwall) {
|
|
120
|
+
message.proceed(true);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const offerwallType = window.googlefc?.MessageTypeEnum?.OFFERWALL;
|
|
125
|
+
if (typeof offerwallType === 'number') {
|
|
126
|
+
message.proceed(false, [offerwallType]);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
message.proceed(true);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function initPrivacySettingsControls(): void {
|
|
135
|
+
const googlefc = (window.googlefc ??= {});
|
|
136
|
+
googlefc.callbackQueue ??= [];
|
|
137
|
+
googlefc.callbackQueue.push({
|
|
138
|
+
CONSENT_API_READY: () => {
|
|
139
|
+
for (const control of document.querySelectorAll<HTMLButtonElement>(
|
|
140
|
+
'[data-ads-consent-settings]:not([data-ads-consent-initialized])',
|
|
141
|
+
)) {
|
|
142
|
+
control.dataset.adsConsentInitialized = 'true';
|
|
143
|
+
control.hidden = false;
|
|
144
|
+
control.addEventListener('click', () => {
|
|
145
|
+
const showRevocationMessage = window.googlefc?.showRevocationMessage;
|
|
146
|
+
if (showRevocationMessage) {
|
|
147
|
+
window.googlefc?.callbackQueue?.push(showRevocationMessage);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function loadAdBlockingRecovery(publisherId: string): void {
|
|
156
|
+
if (document.querySelector('script[data-astronox-ad-recovery]')) return;
|
|
157
|
+
|
|
158
|
+
const script = document.createElement('script');
|
|
159
|
+
script.async = true;
|
|
160
|
+
script.dataset.astronoxAdRecovery = '';
|
|
161
|
+
script.src = `https://fundingchoicesmessages.google.com/i/${encodeURIComponent(publisherId)}?ers=1`;
|
|
162
|
+
document.head.append(script);
|
|
163
|
+
signalFundingChoicesPresence();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function signalFundingChoicesPresence(): void {
|
|
167
|
+
if (document.querySelector('iframe[name="googlefcPresent"]')) return;
|
|
168
|
+
if (!document.body) {
|
|
169
|
+
window.setTimeout(signalFundingChoicesPresence, 0);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const frame = document.createElement('iframe');
|
|
174
|
+
frame.name = 'googlefcPresent';
|
|
175
|
+
frame.hidden = true;
|
|
176
|
+
frame.style.display = 'none';
|
|
177
|
+
document.body.append(frame);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function loadAdsenseScript(clientId: string): Promise<void> {
|
|
181
|
+
if (adsenseScript) return adsenseScript;
|
|
182
|
+
|
|
183
|
+
const loading = new Promise<void>((resolve, reject) => {
|
|
184
|
+
const existing = document.querySelector<HTMLScriptElement>(
|
|
185
|
+
'script[data-astronox-adsense]',
|
|
186
|
+
);
|
|
187
|
+
if (existing?.dataset.loaded === 'true') {
|
|
188
|
+
resolve();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const script = existing ?? document.createElement('script');
|
|
193
|
+
const timeout = window.setTimeout(() => {
|
|
194
|
+
reject(new Error('AdSense loading timed out'));
|
|
195
|
+
}, adsenseScriptTimeoutMs);
|
|
196
|
+
script.addEventListener(
|
|
197
|
+
'load',
|
|
198
|
+
() => {
|
|
199
|
+
window.clearTimeout(timeout);
|
|
200
|
+
script.dataset.loaded = 'true';
|
|
201
|
+
resolve();
|
|
202
|
+
},
|
|
203
|
+
{ once: true },
|
|
204
|
+
);
|
|
205
|
+
script.addEventListener(
|
|
206
|
+
'error',
|
|
207
|
+
() => {
|
|
208
|
+
window.clearTimeout(timeout);
|
|
209
|
+
reject(new Error('AdSense failed to load'));
|
|
210
|
+
},
|
|
211
|
+
{ once: true },
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (!existing) {
|
|
215
|
+
script.async = true;
|
|
216
|
+
script.crossOrigin = 'anonymous';
|
|
217
|
+
script.dataset.astronoxAdsense = '';
|
|
218
|
+
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(clientId)}`;
|
|
219
|
+
document.head.append(script);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
adsenseScript = loading;
|
|
223
|
+
void loading.catch(() => {
|
|
224
|
+
if (adsenseScript === loading) adsenseScript = undefined;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return loading;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function initAdUnits(): void {
|
|
231
|
+
for (const slot of document.querySelectorAll<HTMLElement>(
|
|
232
|
+
'[data-adsense-unit]:not([data-adsense-initialized])',
|
|
233
|
+
)) {
|
|
234
|
+
initAdUnit(slot);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function initAdUnit(slot: HTMLElement): void {
|
|
239
|
+
slot.dataset.adsenseInitialized = 'true';
|
|
240
|
+
setAdSlotState(slot, 'loading');
|
|
241
|
+
const advertisement = slot.querySelector<HTMLElement>('.adsbygoogle');
|
|
242
|
+
if (!advertisement) {
|
|
243
|
+
setAdSlotState(slot, 'unavailable');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const timeout = window.setTimeout(() => {
|
|
248
|
+
setAdSlotState(slot, 'unavailable');
|
|
249
|
+
}, adsenseUnitTimeoutMs);
|
|
250
|
+
const sync = (): void => {
|
|
251
|
+
if (advertisement.dataset.adStatus === 'filled') {
|
|
252
|
+
window.clearTimeout(timeout);
|
|
253
|
+
observer.disconnect();
|
|
254
|
+
setAdSlotState(slot, 'filled');
|
|
255
|
+
} else if (advertisement.dataset.adStatus === 'unfilled') {
|
|
256
|
+
window.clearTimeout(timeout);
|
|
257
|
+
observer.disconnect();
|
|
258
|
+
setAdSlotState(slot, 'unavailable');
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const observer = new MutationObserver(sync);
|
|
262
|
+
observer.observe(advertisement, {
|
|
263
|
+
attributes: true,
|
|
264
|
+
attributeFilter: ['data-ad-status'],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
window.adsbygoogle ??= [];
|
|
269
|
+
window.adsbygoogle.push({});
|
|
270
|
+
sync();
|
|
271
|
+
} catch {
|
|
272
|
+
window.clearTimeout(timeout);
|
|
273
|
+
observer.disconnect();
|
|
274
|
+
setAdSlotState(slot, 'unavailable');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function setAllAdUnitsState(state: 'blocked' | 'unavailable'): void {
|
|
279
|
+
for (const slot of document.querySelectorAll<HTMLElement>(
|
|
280
|
+
'[data-adsense-unit]',
|
|
281
|
+
)) {
|
|
282
|
+
setAdSlotState(slot, state);
|
|
283
|
+
}
|
|
284
|
+
}
|
package/src/ads/contracts.ts
CHANGED
|
@@ -1,11 +1,73 @@
|
|
|
1
1
|
export type AdSlotState =
|
|
2
2
|
'idle' | 'loading' | 'filled' | 'blocked' | 'unavailable';
|
|
3
3
|
|
|
4
|
-
export type
|
|
5
|
-
|
|
4
|
+
export type DisabledAdsConfig = {
|
|
5
|
+
enabled: false;
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
export type AdsenseAdsConfig = {
|
|
9
|
+
enabled: true;
|
|
10
|
+
provider: 'adsense';
|
|
11
|
+
allowedHosts: readonly string[];
|
|
12
|
+
publisherId: string;
|
|
13
|
+
units: {
|
|
14
|
+
pageEnd: string;
|
|
15
|
+
};
|
|
16
|
+
messaging: {
|
|
17
|
+
adBlockingRecovery: boolean;
|
|
18
|
+
offerwall: boolean;
|
|
19
|
+
errorProtectionCode?: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AdsConfig = DisabledAdsConfig | AdsenseAdsConfig;
|
|
24
|
+
|
|
25
|
+
export const adAnnotationExclusionAttributes = {
|
|
26
|
+
'data-ad-annotation': 'exclude',
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export function defineAdsConfig<const TConfig extends AdsConfig>(
|
|
30
|
+
config: TConfig,
|
|
31
|
+
): TConfig {
|
|
32
|
+
if (!config.enabled) return config;
|
|
33
|
+
|
|
34
|
+
if (config.allowedHosts.length === 0) {
|
|
35
|
+
throw new Error('Ad configuration requires at least one allowed hostname');
|
|
36
|
+
}
|
|
37
|
+
for (const host of config.allowedHosts) {
|
|
38
|
+
if (!isValidHostname(host)) {
|
|
39
|
+
throw new Error(`Invalid advertising hostname: ${host}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!/^pub-\d+$/.test(config.publisherId)) {
|
|
43
|
+
throw new Error('Invalid AdSense publisher id');
|
|
44
|
+
}
|
|
45
|
+
if (!/^\d+$/.test(config.units.pageEnd)) {
|
|
46
|
+
throw new Error('Invalid AdSense page-end slot id');
|
|
47
|
+
}
|
|
48
|
+
if (/<\/script/i.test(config.messaging.errorProtectionCode ?? '')) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'AdSense error protection must contain the script body only',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isValidHostname(host: string): boolean {
|
|
58
|
+
if (!host || host !== host.trim() || host.includes('/')) return false;
|
|
59
|
+
if (
|
|
60
|
+
host.toLowerCase() === 'localhost' ||
|
|
61
|
+
host.toLowerCase().endsWith('.localhost') ||
|
|
62
|
+
host === '127.0.0.1' ||
|
|
63
|
+
host === '::1'
|
|
64
|
+
) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
return new URL(`https://${host}`).hostname === host.toLowerCase();
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
11
73
|
}
|
|
@@ -16,38 +16,15 @@
|
|
|
16
16
|
--ad-slot-min-block-size: 15rem;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
.ad-slot__content
|
|
20
|
-
.ad-slot__fallback {
|
|
21
|
-
grid-area: 1 / 1;
|
|
19
|
+
.ad-slot__content {
|
|
22
20
|
inline-size: 100%;
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
.ad-
|
|
26
|
-
|
|
27
|
-
block-size: 100%;
|
|
23
|
+
.ad-slot[data-ad-state='filled'] {
|
|
24
|
+
min-block-size: 0;
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
.ad-
|
|
31
|
-
|
|
32
|
-
min-block-size: var(--ad-slot-min-block-size);
|
|
33
|
-
background:
|
|
34
|
-
linear-gradient(
|
|
35
|
-
135deg,
|
|
36
|
-
transparent 0 46%,
|
|
37
|
-
color-mix(in srgb, var(--nox-theme-border-base) 55%, transparent) 46% 54%,
|
|
38
|
-
transparent 54% 100%
|
|
39
|
-
),
|
|
40
|
-
var(--nox-surface-muted);
|
|
41
|
-
border: 1px solid var(--nox-theme-border-base);
|
|
42
|
-
inline-size: 100%;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
.ad-slot[data-ad-state='blocked'] .ad-slot__content,
|
|
46
|
-
.ad-slot[data-ad-state='unavailable'] .ad-slot__content {
|
|
27
|
+
.ad-slot[data-ad-state='blocked'],
|
|
28
|
+
.ad-slot[data-ad-state='unavailable'] {
|
|
47
29
|
display: none;
|
|
48
30
|
}
|
|
49
|
-
|
|
50
|
-
.ad-slot[data-ad-state='blocked'] .ad-slot__fallback,
|
|
51
|
-
.ad-slot[data-ad-state='unavailable'] .ad-slot__fallback {
|
|
52
|
-
display: block;
|
|
53
|
-
}
|
|
@@ -46,6 +46,7 @@ const resolveRel = (target: FooterItem['target'], rel?: FooterItem['rel']) =>
|
|
|
46
46
|
{items.map(
|
|
47
47
|
(
|
|
48
48
|
{
|
|
49
|
+
button,
|
|
49
50
|
href,
|
|
50
51
|
label: itemLabel,
|
|
51
52
|
marker,
|
|
@@ -83,6 +84,13 @@ const resolveRel = (target: FooterItem['target'], rel?: FooterItem['rel']) =>
|
|
|
83
84
|
) : null}
|
|
84
85
|
<span>{itemLabel}</span>
|
|
85
86
|
</a>
|
|
87
|
+
) : button ? (
|
|
88
|
+
<button {...button} type="button" data-tone={itemTone}>
|
|
89
|
+
{marker === 'arrow' ? (
|
|
90
|
+
<ArrowRightIcon class="site-footer__marker" />
|
|
91
|
+
) : null}
|
|
92
|
+
<span>{itemLabel}</span>
|
|
93
|
+
</button>
|
|
86
94
|
) : (
|
|
87
95
|
<span class="site-footer__label" data-tone={itemTone}>
|
|
88
96
|
{marker === 'arrow' ? (
|
|
@@ -78,11 +78,12 @@
|
|
|
78
78
|
flex: 0 0 auto;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
.site-footer__prefix + :is(a, .site-footer__label) {
|
|
81
|
+
.site-footer__prefix + :is(a, button, .site-footer__label) {
|
|
82
82
|
margin-inline-start: var(--nox-space-1);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
.site-footer a,
|
|
86
|
+
.site-footer button,
|
|
86
87
|
.site-footer__label {
|
|
87
88
|
display: inline-flex;
|
|
88
89
|
min-width: 0;
|
|
@@ -98,35 +99,48 @@
|
|
|
98
99
|
color: var(--nox-text-muted);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
.site-footer a {
|
|
102
|
+
.site-footer :is(a, button) {
|
|
102
103
|
color: var(--nox-content-link-color);
|
|
103
104
|
font-weight: var(--nox-content-link-emphasis-font-weight);
|
|
104
105
|
text-decoration: none;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
.site-footer button {
|
|
109
|
+
padding: 0;
|
|
110
|
+
border: 0;
|
|
111
|
+
background: transparent;
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
font: inherit;
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
.site-footer a[data-tone],
|
|
117
|
+
.site-footer button[data-tone],
|
|
108
118
|
.site-footer__label[data-tone] {
|
|
109
119
|
color: var(--nox-tone-color);
|
|
110
120
|
}
|
|
111
121
|
|
|
112
|
-
.site-footer a:focus-visible {
|
|
122
|
+
.site-footer :is(a, button):focus-visible {
|
|
113
123
|
border-radius: var(--nox-radius-sm);
|
|
114
124
|
outline: var(--nox-focus-ring-width) solid var(--nox-tone-focus-ring);
|
|
115
125
|
outline-offset: var(--nox-focus-ring-offset);
|
|
116
126
|
color: var(--nox-content-link-color);
|
|
117
127
|
}
|
|
118
128
|
|
|
119
|
-
.site-footer a[data-tone]:focus-visible {
|
|
129
|
+
.site-footer :is(a, button)[data-tone]:focus-visible {
|
|
120
130
|
color: var(--nox-tone-color);
|
|
121
131
|
}
|
|
122
132
|
|
|
133
|
+
.site-footer button[hidden] {
|
|
134
|
+
display: none;
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
@media (hover: hover) and (pointer: fine) {
|
|
124
|
-
.site-footer a:hover {
|
|
138
|
+
.site-footer :is(a, button):hover {
|
|
125
139
|
color: var(--nox-content-link-color);
|
|
126
140
|
text-decoration-line: underline;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
.site-footer a[data-tone]:hover {
|
|
143
|
+
.site-footer :is(a, button)[data-tone]:hover {
|
|
130
144
|
color: var(--nox-tone-color);
|
|
131
145
|
}
|
|
132
146
|
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import type { HTMLAttributes } from 'astro/types';
|
|
3
3
|
import type { Tone } from '../../theme/contracts';
|
|
4
4
|
|
|
5
|
-
type SurfaceGroupElement = 'div' | 'section' | 'nav';
|
|
5
|
+
type SurfaceGroupElement = 'article' | 'div' | 'section' | 'nav';
|
|
6
6
|
type SurfaceGroupAttributes = HTMLAttributes<'div'> &
|
|
7
|
+
HTMLAttributes<'article'> &
|
|
7
8
|
HTMLAttributes<'section'> &
|
|
8
9
|
HTMLAttributes<'nav'>;
|
|
9
10
|
|
|
@@ -14,14 +14,64 @@ export function readOptionalEnvironmentValue(
|
|
|
14
14
|
return resolved || undefined;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export function readRequiredEnvironmentBoolean(
|
|
18
|
+
name: string,
|
|
19
|
+
value: string | undefined,
|
|
20
|
+
): boolean {
|
|
21
|
+
const resolved = readRequiredEnvironmentValue(name, value);
|
|
22
|
+
if (resolved === 'true') return true;
|
|
23
|
+
if (resolved === 'false') return false;
|
|
24
|
+
throw new Error(`Invalid environment boolean: ${name}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readEnvironmentStringArray(
|
|
28
|
+
name: string,
|
|
29
|
+
value: string | undefined,
|
|
30
|
+
): readonly string[] {
|
|
31
|
+
const resolved = readRequiredEnvironmentValue(name, value);
|
|
32
|
+
let entries: unknown[];
|
|
33
|
+
|
|
34
|
+
if (resolved.startsWith('[')) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed: unknown = JSON.parse(resolved);
|
|
37
|
+
if (!Array.isArray(parsed)) throw new Error();
|
|
38
|
+
entries = parsed;
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error(`Invalid environment string array: ${name}`);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
entries = [resolved];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
entries.length === 0 ||
|
|
48
|
+
entries.some((entry) => typeof entry !== 'string' || !entry.trim())
|
|
49
|
+
) {
|
|
50
|
+
throw new Error(`Invalid environment string array: ${name}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return [...new Set(entries.map((entry) => (entry as string).trim()))];
|
|
54
|
+
}
|
|
55
|
+
|
|
17
56
|
export function readRequiredEnvironmentUrl(
|
|
18
57
|
name: string,
|
|
19
58
|
value: string | undefined,
|
|
20
59
|
): string {
|
|
21
60
|
const resolved = readRequiredEnvironmentValue(name, value);
|
|
61
|
+
return parseEnvironmentUrl(name, resolved);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readOptionalEnvironmentUrl(
|
|
65
|
+
name: string,
|
|
66
|
+
value: string | undefined,
|
|
67
|
+
): string | undefined {
|
|
68
|
+
const resolved = readOptionalEnvironmentValue(value);
|
|
69
|
+
return resolved ? parseEnvironmentUrl(name, resolved) : undefined;
|
|
70
|
+
}
|
|
22
71
|
|
|
72
|
+
function parseEnvironmentUrl(name: string, value: string): string {
|
|
23
73
|
try {
|
|
24
|
-
const url = new URL(
|
|
74
|
+
const url = new URL(value);
|
|
25
75
|
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
26
76
|
throw new Error();
|
|
27
77
|
return url.toString();
|
|
@@ -35,9 +85,23 @@ export function readEnvironmentRecord(
|
|
|
35
85
|
value: string | undefined,
|
|
36
86
|
): Readonly<Record<string, string>> {
|
|
37
87
|
const resolved = readRequiredEnvironmentValue(name, value);
|
|
88
|
+
return parseEnvironmentRecord(name, resolved);
|
|
89
|
+
}
|
|
38
90
|
|
|
91
|
+
export function readOptionalEnvironmentRecord(
|
|
92
|
+
name: string,
|
|
93
|
+
value: string | undefined,
|
|
94
|
+
): Readonly<Record<string, string>> | undefined {
|
|
95
|
+
const resolved = readOptionalEnvironmentValue(value);
|
|
96
|
+
return resolved ? parseEnvironmentRecord(name, resolved) : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseEnvironmentRecord(
|
|
100
|
+
name: string,
|
|
101
|
+
value: string,
|
|
102
|
+
): Readonly<Record<string, string>> {
|
|
39
103
|
try {
|
|
40
|
-
const parsed: unknown = JSON.parse(
|
|
104
|
+
const parsed: unknown = JSON.parse(value);
|
|
41
105
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
42
106
|
throw new Error();
|
|
43
107
|
}
|
|
@@ -6,6 +6,7 @@ import type { PageMeta } from '../config/pageMeta';
|
|
|
6
6
|
import type { SiteConfig } from '../config/siteConfig';
|
|
7
7
|
import type { JsonLdNode } from '../seo/structuredData';
|
|
8
8
|
import type { Tone } from '../theme/contracts';
|
|
9
|
+
import { adAnnotationExclusionAttributes } from '../ads/contracts';
|
|
9
10
|
import PageSurface from './PageSurface.astro';
|
|
10
11
|
import Breadcrumbs from '../components/navigation/Breadcrumbs/Breadcrumbs.astro';
|
|
11
12
|
import ScrollToTop from '../components/actions/ScrollToTop/ScrollToTop.astro';
|
|
@@ -15,6 +16,7 @@ import { defaultThemeStorageKey } from '../theme/storage';
|
|
|
15
16
|
type Props = HTMLAttributes<'html'> & {
|
|
16
17
|
meta: PageMeta;
|
|
17
18
|
site: SiteConfig;
|
|
19
|
+
pageSurfaceAttributes?: HTMLAttributes<'div'>;
|
|
18
20
|
currentPath?: string;
|
|
19
21
|
breadcrumbs?: readonly BreadcrumbItem[];
|
|
20
22
|
breadcrumbLabel: string;
|
|
@@ -32,6 +34,7 @@ const {
|
|
|
32
34
|
currentPath = Astro.url.pathname,
|
|
33
35
|
'data-tone': dataTone,
|
|
34
36
|
meta,
|
|
37
|
+
pageSurfaceAttributes,
|
|
35
38
|
scrollToTopLabel,
|
|
36
39
|
site,
|
|
37
40
|
structuredData,
|
|
@@ -67,6 +70,7 @@ const resolvedTone = tone ?? dataTone;
|
|
|
67
70
|
{
|
|
68
71
|
breadcrumbs?.length ? (
|
|
69
72
|
<Breadcrumbs
|
|
73
|
+
{...adAnnotationExclusionAttributes}
|
|
70
74
|
items={breadcrumbs}
|
|
71
75
|
label={breadcrumbLabel}
|
|
72
76
|
tone={breadcrumbTone}
|
|
@@ -74,8 +78,9 @@ const resolvedTone = tone ?? dataTone;
|
|
|
74
78
|
) : null
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
<PageSurface>
|
|
81
|
+
<PageSurface {...pageSurfaceAttributes}>
|
|
78
82
|
<slot />
|
|
83
|
+
<slot name="page-end" />
|
|
79
84
|
</PageSurface>
|
|
80
85
|
</div>
|
|
81
86
|
</main>
|
package/src/ads/adsense.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import type { AdConsentProvider, AdProvider } from './contracts';
|
|
2
|
-
import { setAdSlotState } from './slot';
|
|
3
|
-
|
|
4
|
-
declare global {
|
|
5
|
-
interface Window {
|
|
6
|
-
adsbygoogle?: Record<string, unknown>[];
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function createAdsenseProvider({
|
|
11
|
-
clientId,
|
|
12
|
-
consent,
|
|
13
|
-
loadTimeoutMs,
|
|
14
|
-
}: {
|
|
15
|
-
clientId: string;
|
|
16
|
-
consent: AdConsentProvider;
|
|
17
|
-
loadTimeoutMs: number;
|
|
18
|
-
}): AdProvider {
|
|
19
|
-
let loading: Promise<void> | null = null;
|
|
20
|
-
|
|
21
|
-
const load = async (): Promise<void> => {
|
|
22
|
-
if (!(await consent.canLoadAds())) {
|
|
23
|
-
throw new Error('Advertising consent was not granted');
|
|
24
|
-
}
|
|
25
|
-
loading ??= loadAdsenseScript(clientId, loadTimeoutMs).catch((reason) => {
|
|
26
|
-
loading = null;
|
|
27
|
-
throw reason;
|
|
28
|
-
});
|
|
29
|
-
return loading;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
load,
|
|
34
|
-
async request(slot) {
|
|
35
|
-
setAdSlotState(slot, 'loading');
|
|
36
|
-
try {
|
|
37
|
-
await load();
|
|
38
|
-
window.adsbygoogle ??= [];
|
|
39
|
-
window.adsbygoogle.push({});
|
|
40
|
-
observeAdsenseSlot(slot);
|
|
41
|
-
} catch (reason) {
|
|
42
|
-
setAdSlotState(slot, 'blocked');
|
|
43
|
-
throw reason;
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function loadAdsenseScript(
|
|
50
|
-
clientId: string,
|
|
51
|
-
loadTimeoutMs: number,
|
|
52
|
-
): Promise<void> {
|
|
53
|
-
const existing = document.querySelector<HTMLScriptElement>(
|
|
54
|
-
'script[data-astronox-adsense]',
|
|
55
|
-
);
|
|
56
|
-
if (existing?.dataset.loaded === 'true') return Promise.resolve();
|
|
57
|
-
|
|
58
|
-
return new Promise((resolve, reject) => {
|
|
59
|
-
const script = existing ?? document.createElement('script');
|
|
60
|
-
script.async = true;
|
|
61
|
-
script.crossOrigin = 'anonymous';
|
|
62
|
-
script.dataset.astronoxAdsense = '';
|
|
63
|
-
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(clientId)}`;
|
|
64
|
-
const timeout = window.setTimeout(
|
|
65
|
-
() => reject(new Error('AdSense loading timed out')),
|
|
66
|
-
loadTimeoutMs,
|
|
67
|
-
);
|
|
68
|
-
script.addEventListener(
|
|
69
|
-
'load',
|
|
70
|
-
() => {
|
|
71
|
-
window.clearTimeout(timeout);
|
|
72
|
-
script.dataset.loaded = 'true';
|
|
73
|
-
resolve();
|
|
74
|
-
},
|
|
75
|
-
{ once: true },
|
|
76
|
-
);
|
|
77
|
-
script.addEventListener(
|
|
78
|
-
'error',
|
|
79
|
-
() => {
|
|
80
|
-
window.clearTimeout(timeout);
|
|
81
|
-
reject(new Error('AdSense failed to load'));
|
|
82
|
-
},
|
|
83
|
-
{ once: true },
|
|
84
|
-
);
|
|
85
|
-
if (!existing) document.head.append(script);
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function observeAdsenseSlot(slot: HTMLElement): void {
|
|
90
|
-
const advertisement = slot.querySelector<HTMLElement>('.adsbygoogle');
|
|
91
|
-
if (!advertisement) {
|
|
92
|
-
setAdSlotState(slot, 'unavailable');
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const sync = (): void => {
|
|
97
|
-
const status = advertisement.dataset.adStatus;
|
|
98
|
-
if (status === 'filled') setAdSlotState(slot, 'filled');
|
|
99
|
-
if (status === 'unfilled') setAdSlotState(slot, 'unavailable');
|
|
100
|
-
};
|
|
101
|
-
sync();
|
|
102
|
-
if (advertisement.dataset.adStatus) return;
|
|
103
|
-
|
|
104
|
-
const observer = new MutationObserver(() => {
|
|
105
|
-
sync();
|
|
106
|
-
if (advertisement.dataset.adStatus) observer.disconnect();
|
|
107
|
-
});
|
|
108
|
-
observer.observe(advertisement, {
|
|
109
|
-
attributeFilter: ['data-ad-status'],
|
|
110
|
-
attributes: true,
|
|
111
|
-
});
|
|
112
|
-
}
|