@astronox/core 0.3.1 → 0.4.1

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.
Files changed (39) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/ads/adsense/AdsenseHead.astro +55 -0
  4. package/src/ads/adsense/AdsenseUnit.astro +45 -0
  5. package/src/ads/adsense/index.ts +3 -1
  6. package/src/ads/adsense/provider.ts +41 -0
  7. package/src/ads/adsense/runtime.ts +284 -0
  8. package/src/ads/contracts.ts +67 -5
  9. package/src/components/content/CardHeader/CardHeader.astro +20 -0
  10. package/src/components/content/CardHeader/CardHeader.css +21 -0
  11. package/src/components/feedback/Tooltip/tooltipController.ts +37 -5
  12. package/src/components/monetization/AdSlot/AdSlot.astro +0 -3
  13. package/src/components/monetization/AdSlot/AdSlot.css +5 -28
  14. package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +2 -0
  15. package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -6
  16. package/src/components/navigation/Footer/Footer.astro +8 -0
  17. package/src/components/navigation/Footer/Footer.css +20 -6
  18. package/src/components/navigation/Footer/contracts.ts +1 -0
  19. package/src/composition/Grid/Grid.css +1 -1
  20. package/src/composition/SurfaceGroup/SurfaceGroup.astro +2 -1
  21. package/src/composition/SurfaceGroup/SurfaceGroup.css +18 -18
  22. package/src/composition/SurfaceGroup/stickySurfaceGroupController.ts +9 -0
  23. package/src/config/environment.ts +66 -2
  24. package/src/layouts/BaseLayout.astro +6 -1
  25. package/src/patterns/Combobox/comboboxController.ts +51 -12
  26. package/src/patterns/Dropdown/dropdownController.ts +17 -6
  27. package/src/patterns/Fullscreen/FullscreenControl.astro +3 -30
  28. package/src/patterns/Fullscreen/fullscreenController.ts +18 -22
  29. package/src/patterns/Table/Table.css +8 -2
  30. package/src/patterns/Table/TableFilterPanel.astro +84 -0
  31. package/src/patterns/Table/TableFilterPanel.css +139 -0
  32. package/src/patterns/Table/TableSkeleton.css +0 -3
  33. package/src/patterns/Table/tableFilterPanelController.ts +91 -0
  34. package/src/patterns/Table/tableProgressiveController.ts +78 -16
  35. package/src/patterns/Table/tableScrollPosition.ts +41 -0
  36. package/src/patterns/Table/tableSortController.ts +6 -3
  37. package/src/patterns/index.ts +2 -0
  38. package/src/styles/style.css +1 -0
  39. package/src/ads/adsense.ts +0 -112
@@ -72,6 +72,7 @@ export function initTooltip(root: HTMLElement): () => void {
72
72
  ':scope > [data-tooltip-panel]',
73
73
  );
74
74
  if (!trigger || !panel) return noop;
75
+ const activeTrigger = trigger;
75
76
 
76
77
  const hoverOnly = root.dataset.tooltipActivation === 'hover';
77
78
  root.setAttribute(initializedAttribute, 'true');
@@ -106,6 +107,7 @@ export function initTooltip(root: HTMLElement): () => void {
106
107
  let hoveredPanel = false;
107
108
  let openListenersActive = false;
108
109
  let pinned = false;
110
+ let lastPointerType = '';
109
111
 
110
112
  const isOpen = (): boolean => panel.matches(':popover-open');
111
113
 
@@ -239,12 +241,12 @@ export function initTooltip(root: HTMLElement): () => void {
239
241
  };
240
242
 
241
243
  const handleTriggerPointerEnter = (event: PointerEvent): void => {
242
- if (hoverOnly && event.pointerType === 'touch') return;
244
+ if (event.pointerType === 'touch') return;
243
245
  hoveredTrigger = true;
244
246
  show();
245
247
  };
246
248
  const handleTriggerPointerLeave = (event: PointerEvent): void => {
247
- if (hoverOnly && event.pointerType === 'touch') return;
249
+ if (event.pointerType === 'touch') return;
248
250
  hoveredTrigger = false;
249
251
 
250
252
  if (hoverOnly) {
@@ -263,6 +265,7 @@ export function initTooltip(root: HTMLElement): () => void {
263
265
  scheduleClose();
264
266
  };
265
267
  const handleFocusIn = (): void => {
268
+ if (lastPointerType === 'touch') return;
266
269
  if (
267
270
  hoverOnly &&
268
271
  !trigger.matches(':focus-visible') &&
@@ -274,7 +277,23 @@ export function initTooltip(root: HTMLElement): () => void {
274
277
  show();
275
278
  };
276
279
  const handleFocusOut = (): void => scheduleClose();
277
- const handleTriggerClick = (): void => {
280
+ const handleTriggerPointerDown = (event: PointerEvent): void => {
281
+ lastPointerType = event.pointerType;
282
+ };
283
+ const handleTriggerKeyDown = (): void => {
284
+ lastPointerType = '';
285
+ };
286
+ const handleTriggerClick = (event: MouseEvent): void => {
287
+ const pointerType =
288
+ 'PointerEvent' in window && event instanceof PointerEvent
289
+ ? event.pointerType
290
+ : lastPointerType;
291
+ if (hoverOnly && pointerType !== 'touch') return;
292
+ if (pointerType === 'touch') {
293
+ event.preventDefault();
294
+ event.stopPropagation();
295
+ }
296
+
278
297
  if (pinned) {
279
298
  close();
280
299
  return;
@@ -299,6 +318,12 @@ export function initTooltip(root: HTMLElement): () => void {
299
318
  if (!(event instanceof CustomEvent) || event.detail?.root === root) return;
300
319
  close();
301
320
  };
321
+ const visibilityObserver =
322
+ 'IntersectionObserver' in window
323
+ ? new IntersectionObserver((entries) => {
324
+ if (entries.some((entry) => !entry.isIntersecting)) close();
325
+ })
326
+ : null;
302
327
  function addOpenListeners(): void {
303
328
  if (openListenersActive) return;
304
329
  openListenersActive = true;
@@ -309,6 +334,7 @@ export function initTooltip(root: HTMLElement): () => void {
309
334
  window.addEventListener('scroll', requestPosition, true);
310
335
  window.visualViewport?.addEventListener('resize', requestPosition);
311
336
  window.visualViewport?.addEventListener('scroll', requestPosition);
337
+ visibilityObserver?.observe(activeTrigger);
312
338
  }
313
339
  function removeOpenListeners(): void {
314
340
  if (!openListenersActive) return;
@@ -320,13 +346,16 @@ export function initTooltip(root: HTMLElement): () => void {
320
346
  window.removeEventListener('scroll', requestPosition, true);
321
347
  window.visualViewport?.removeEventListener('resize', requestPosition);
322
348
  window.visualViewport?.removeEventListener('scroll', requestPosition);
349
+ visibilityObserver?.unobserve(activeTrigger);
323
350
  }
324
351
 
352
+ trigger.addEventListener('pointerdown', handleTriggerPointerDown);
325
353
  trigger.addEventListener('pointerenter', handleTriggerPointerEnter);
326
354
  trigger.addEventListener('pointerleave', handleTriggerPointerLeave);
327
355
  trigger.addEventListener('focusin', handleFocusIn);
328
356
  trigger.addEventListener('focusout', handleFocusOut);
329
- if (!hoverOnly) trigger.addEventListener('click', handleTriggerClick);
357
+ trigger.addEventListener('keydown', handleTriggerKeyDown);
358
+ trigger.addEventListener('click', handleTriggerClick);
330
359
  if (!hoverOnly) {
331
360
  panel.addEventListener('pointerenter', handlePanelPointerEnter);
332
361
  panel.addEventListener('pointerleave', handlePanelPointerLeave);
@@ -338,11 +367,14 @@ export function initTooltip(root: HTMLElement): () => void {
338
367
  if (positionFrame !== undefined) {
339
368
  window.cancelAnimationFrame(positionFrame);
340
369
  }
370
+ visibilityObserver?.disconnect();
371
+ trigger.removeEventListener('pointerdown', handleTriggerPointerDown);
341
372
  trigger.removeEventListener('pointerenter', handleTriggerPointerEnter);
342
373
  trigger.removeEventListener('pointerleave', handleTriggerPointerLeave);
343
374
  trigger.removeEventListener('focusin', handleFocusIn);
344
375
  trigger.removeEventListener('focusout', handleFocusOut);
345
- if (!hoverOnly) trigger.removeEventListener('click', handleTriggerClick);
376
+ trigger.removeEventListener('keydown', handleTriggerKeyDown);
377
+ trigger.removeEventListener('click', handleTriggerClick);
346
378
  if (!hoverOnly) {
347
379
  panel.removeEventListener('pointerenter', handlePanelPointerEnter);
348
380
  panel.removeEventListener('pointerleave', handlePanelPointerLeave);
@@ -27,7 +27,4 @@ const {
27
27
  aria-busy={state === 'loading' ? 'true' : 'false'}
28
28
  >
29
29
  <div class="ad-slot__content"><slot /></div>
30
- <div class="ad-slot__fallback">
31
- <slot name="fallback"><div class="ad-slot__fallback-canvas"></div></slot>
32
- </div>
33
30
  </aside>
@@ -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-slot__fallback {
26
- display: none;
27
- block-size: 100%;
23
+ .ad-slot[data-ad-state='filled'] {
24
+ min-block-size: 0;
28
25
  }
29
26
 
30
- .ad-slot__fallback-canvas {
31
- block-size: 100%;
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
- }
@@ -16,6 +16,7 @@ const {
16
16
  'data-tone': dataTone,
17
17
  items,
18
18
  label = defaultCoreMessages.navigation.breadcrumbs,
19
+ tabindex: tabIndex = 0,
19
20
  tone,
20
21
  ...attrs
21
22
  } = Astro.props;
@@ -28,6 +29,7 @@ const resolvedTone = tone ?? dataTone ?? 'info';
28
29
  {...attrs}
29
30
  class:list={['breadcrumbs', className]}
30
31
  aria-label={label}
32
+ tabindex={tabIndex}
31
33
  data-widget="breadcrumbs"
32
34
  data-tone={resolvedTone}
33
35
  >
@@ -1,17 +1,26 @@
1
1
  .breadcrumbs {
2
2
  min-width: 0;
3
- overflow: hidden;
3
+ overflow-x: auto;
4
+ overflow-y: hidden;
5
+ overscroll-behavior-inline: contain;
6
+ scrollbar-width: thin;
7
+ touch-action: pan-x pan-y;
4
8
  color: var(--nox-tone-color);
5
9
  font-size: var(--nox-font-size-sm);
6
10
  font-weight: var(--nox-font-weight-medium);
7
11
  line-height: 1.35;
8
12
  }
9
13
 
14
+ .breadcrumbs:focus-visible {
15
+ outline: var(--nox-focus-ring-width) solid var(--nox-tone-focus-ring);
16
+ outline-offset: calc(var(--nox-focus-ring-width) * -1);
17
+ }
18
+
10
19
  .breadcrumbs__list {
11
20
  display: flex;
12
21
  flex-wrap: nowrap;
13
22
  align-items: stretch;
14
- min-width: 0;
23
+ min-width: max-content;
15
24
  min-block-size: calc(
16
25
  1.35em + var(--site-main-content-gap, var(--nox-space-5)) +
17
26
  var(--site-main-content-gap, var(--nox-space-5))
@@ -30,8 +39,7 @@
30
39
  }
31
40
 
32
41
  .breadcrumbs__item:last-child {
33
- flex: 1 1 auto;
34
- overflow: hidden;
42
+ flex: 0 0 auto;
35
43
  }
36
44
 
37
45
  .breadcrumbs__item > span {
@@ -63,8 +71,6 @@
63
71
  }
64
72
 
65
73
  .breadcrumbs__item:last-child > :is(a, span) {
66
- overflow: hidden;
67
- text-overflow: ellipsis;
68
74
  white-space: nowrap;
69
75
  }
70
76
 
@@ -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,6 +2,7 @@ import type { HTMLAttributes } from 'astro/types';
2
2
  import type { Tone } from '../../../theme/contracts';
3
3
 
4
4
  export type FooterItem = {
5
+ button?: Omit<HTMLAttributes<'button'>, 'type'>;
5
6
  href?: HTMLAttributes<'a'>['href'];
6
7
  label: string;
7
8
  marker?: 'arrow';
@@ -1,5 +1,5 @@
1
1
  .grid-container {
2
- container-type: inline-size;
2
+ container: nox-grid / inline-size;
3
3
  min-width: 0;
4
4
  }
5
5
 
@@ -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
 
@@ -17,24 +17,32 @@
17
17
  }
18
18
 
19
19
  .surface-group:has(> [data-sticky-surface])::before {
20
- --surface-group-sticky-occlusion-size: calc(
21
- var(--nox-layout-gap) + var(--nox-section-responsive-block-size)
22
- );
23
-
24
20
  position: sticky;
25
21
  z-index: 39;
26
22
  top: 0;
27
23
  display: block;
28
24
  min-width: 0;
29
- min-height: var(--surface-group-sticky-occlusion-size);
25
+ min-height: var(
26
+ --surface-group-sticky-occlusion-size,
27
+ calc(var(--nox-layout-gap) + var(--nox-section-responsive-block-size))
28
+ );
30
29
  visibility: hidden;
31
- flex: 0 0 var(--surface-group-sticky-occlusion-size);
32
- margin-block-end: calc(-1 * var(--surface-group-sticky-occlusion-size));
30
+ flex: 0 0
31
+ var(
32
+ --surface-group-sticky-occlusion-size,
33
+ calc(var(--nox-layout-gap) + var(--nox-section-responsive-block-size))
34
+ );
35
+ margin-block-end: calc(
36
+ -1 *
37
+ var(
38
+ --surface-group-sticky-occlusion-size,
39
+ calc(var(--nox-layout-gap) + var(--nox-section-responsive-block-size))
40
+ )
41
+ );
33
42
  background: var(--nox-page-background);
34
43
  content: '';
35
44
  }
36
45
 
37
- .surface-group:has(> [data-sticky-surface]):not([data-sticky-measured])::before,
38
46
  .surface-group[data-sticky-active]::before {
39
47
  visibility: visible;
40
48
  }
@@ -61,8 +69,8 @@
61
69
 
62
70
  .surface-group
63
71
  > [data-surface]:not([hidden]):has(~ [data-surface]:not([hidden])) {
64
- border-end-start-radius: 0;
65
- border-end-end-radius: 0;
72
+ border-end-start-radius: var(--surface-group-connected-border-end-radius, 0);
73
+ border-end-end-radius: var(--surface-group-connected-border-end-radius, 0);
66
74
  }
67
75
 
68
76
  .surface-group > [data-surface]:not([hidden]) ~ [data-surface]:not([hidden]) {
@@ -70,11 +78,3 @@
70
78
  border-start-start-radius: 0;
71
79
  border-start-end-radius: 0;
72
80
  }
73
-
74
- @container nox-surface-group (width < 32rem) {
75
- .surface-group:has(> [data-sticky-surface])::before {
76
- --surface-group-sticky-occlusion-size: calc(
77
- var(--nox-layout-gap) + var(--nox-section-compact-block-size)
78
- );
79
- }
80
- }
@@ -46,6 +46,10 @@ export const initStickySurfaceGroup = (root: HTMLElement): (() => void) => {
46
46
 
47
47
  const stickyOffset =
48
48
  Number.parseFloat(window.getComputedStyle(stickySurface).top) || 0;
49
+ root.style.setProperty(
50
+ '--surface-group-sticky-occlusion-size',
51
+ `${stickyOffset + stickySurface.getBoundingClientRect().height}px`,
52
+ );
49
53
  observer = new IntersectionObserver(
50
54
  ([entry]) => {
51
55
  if (!entry) return;
@@ -74,10 +78,14 @@ export const initStickySurfaceGroup = (root: HTMLElement): (() => void) => {
74
78
 
75
79
  root.setAttribute('data-sticky-initialized', 'true');
76
80
  window.addEventListener('resize', scheduleObserve, { passive: true });
81
+ const resizeObserver =
82
+ 'ResizeObserver' in window ? new ResizeObserver(scheduleObserve) : null;
83
+ resizeObserver?.observe(stickySurface);
77
84
  observe();
78
85
 
79
86
  return () => {
80
87
  window.removeEventListener('resize', scheduleObserve);
88
+ resizeObserver?.disconnect();
81
89
  observer?.disconnect();
82
90
  sentinel.remove();
83
91
 
@@ -88,5 +96,6 @@ export const initStickySurfaceGroup = (root: HTMLElement): (() => void) => {
88
96
  root.removeAttribute('data-sticky-active');
89
97
  root.removeAttribute('data-sticky-initialized');
90
98
  root.removeAttribute('data-sticky-measured');
99
+ root.style.removeProperty('--surface-group-sticky-occlusion-size');
91
100
  };
92
101
  };
@@ -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(resolved);
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(resolved);
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>