@sarimarcus/content-sites-ui 0.15.1 → 0.17.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/CHANGELOG.md +15 -0
- package/README.md +74 -1
- package/package.json +8 -3
- package/src/affiliate/EmeraldLoader.astro +40 -0
- package/src/affiliate/index.ts +4 -1
- package/src/affiliate/resources.ts +7 -0
- package/src/affiliate/travelpayoutsStyler.ts +557 -0
- package/src/layout/AsyncStylesheet.astro +14 -0
- package/src/layout/ConsentAnalytics.astro +121 -0
- package/src/layout/DocumentShell.astro +171 -0
- package/src/layout/analytics.ts +151 -0
- package/src/layout/index.ts +7 -0
- package/src/layout/resources.ts +19 -0
- package/src/navigation/MobileNavLink.astro +38 -0
- package/src/navigation/MobileNavSection.astro +73 -0
- package/src/navigation/NavDropdown.astro +84 -0
- package/src/navigation/NavMenuLink.astro +53 -0
- package/src/navigation/SiteFooter.astro +105 -0
- package/src/navigation/SiteHeader.astro +115 -0
- package/src/navigation/activePath.ts +11 -0
- package/src/navigation/footer.ts +19 -0
- package/src/navigation/index.ts +10 -2
- package/src/navigation/navigation.ts +155 -0
- package/src/navigation/tones.ts +33 -0
- package/src/navigation/types.ts +33 -0
- package/src/search/SearchSuggestions.astro +26 -0
- package/src/search/index.ts +5 -0
- package/src/search/renderResult.ts +117 -0
- package/src/styles/consent.css +274 -0
- package/src/styles/search.css +629 -0
- package/src/styles/theme.css +5 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Google Tag Manager behind Consent Mode v2, with the Silktide consent manager. Production only. Deferred to the first
|
|
4
|
+
* interaction, or to idle after load (8 s timeout keeps GTM outside the Lighthouse TBT window). Inline on purpose:
|
|
5
|
+
* the consent default must be pushed before the gtm.js event in one synchronous step, which a deferred module cannot
|
|
6
|
+
* guarantee. Defines `window.openCookiePreferences`, which the footer's cookie-preferences button calls.
|
|
7
|
+
* The `<noscript>` iframe goes at the start of `<body>`: render with `part="noscript"` there and `part="script"` at
|
|
8
|
+
* the end of `<body>`.
|
|
9
|
+
*/
|
|
10
|
+
import { GTM_ORIGIN, SILKTIDE_BASE } from './resources.ts';
|
|
11
|
+
|
|
12
|
+
export interface ConsentType {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
description: string;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
defaultValue?: boolean;
|
|
18
|
+
/** The Consent Mode v2 storage key(s) this category grants. */
|
|
19
|
+
gtag?: string | string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
gtmId: string;
|
|
24
|
+
part: 'noscript' | 'script';
|
|
25
|
+
/** The banner's prompt text. */
|
|
26
|
+
prompt?: string;
|
|
27
|
+
consentTypes?: ConsentType[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { gtmId, part, prompt = '', consentTypes = [] } = Astro.props;
|
|
31
|
+
const isProd = import.meta.env.PROD;
|
|
32
|
+
const gtmOrigin = GTM_ORIGIN;
|
|
33
|
+
const silktideScript = `${SILKTIDE_BASE}/silktide-consent-manager.js`;
|
|
34
|
+
const silktideConfig = { text: { prompt: { description: prompt } }, consentTypes };
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
{isProd && part === 'noscript' && (
|
|
38
|
+
<noscript set:html={`<iframe src="${gtmOrigin}/ns.html?id=${gtmId}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`} />
|
|
39
|
+
)}
|
|
40
|
+
{isProd && part === 'script' && (
|
|
41
|
+
<script is:inline define:vars={{ gtmId, gtmOrigin, silktideScript, silktideConfig }}>
|
|
42
|
+
(function () {
|
|
43
|
+
let thirdPartyLoaded = false;
|
|
44
|
+
|
|
45
|
+
function loadThirdPartyScripts() {
|
|
46
|
+
if (thirdPartyLoaded) return;
|
|
47
|
+
thirdPartyLoaded = true;
|
|
48
|
+
|
|
49
|
+
window.dataLayer = window.dataLayer || [];
|
|
50
|
+
|
|
51
|
+
// GTM treats a dataLayer entry as a consent command only when it is an `arguments` object; a plain array is
|
|
52
|
+
// ignored, which leaves analytics_storage unset and blocks tags with an additional consent check.
|
|
53
|
+
function gtag() { window.dataLayer.push(arguments); }
|
|
54
|
+
gtag('consent', 'default', {
|
|
55
|
+
ad_storage: 'denied',
|
|
56
|
+
ad_user_data: 'denied',
|
|
57
|
+
ad_personalization: 'denied',
|
|
58
|
+
analytics_storage: 'granted',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
window.dataLayer.push({
|
|
62
|
+
'gtm.start': new Date().getTime(),
|
|
63
|
+
event: 'gtm.js',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// On the main thread: Partytown race-dropped the initial page_view (SLA-2661).
|
|
67
|
+
var gtmScript = document.createElement('script');
|
|
68
|
+
gtmScript.async = true;
|
|
69
|
+
gtmScript.src = gtmOrigin + '/gtm.js?id=' + gtmId;
|
|
70
|
+
document.head.appendChild(gtmScript);
|
|
71
|
+
|
|
72
|
+
var consentScript = document.createElement('script');
|
|
73
|
+
consentScript.src = silktideScript;
|
|
74
|
+
consentScript.async = true;
|
|
75
|
+
consentScript.onload = function () {
|
|
76
|
+
if (!window.silktideConsentManager) return;
|
|
77
|
+
window.silktideConsentManager.init(silktideConfig);
|
|
78
|
+
};
|
|
79
|
+
document.head.appendChild(consentScript);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
var evtOpts = { once: true, passive: true, capture: true };
|
|
83
|
+
['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach(function (evt) {
|
|
84
|
+
window.addEventListener(evt, loadThirdPartyScripts, evtOpts);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function scheduleThirdPartyScripts() {
|
|
88
|
+
if ('requestIdleCallback' in window) {
|
|
89
|
+
window.requestIdleCallback(loadThirdPartyScripts, { timeout: 8000 });
|
|
90
|
+
} else {
|
|
91
|
+
setTimeout(loadThirdPartyScripts, 6000);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (document.readyState === 'complete') {
|
|
96
|
+
scheduleThirdPartyScripts();
|
|
97
|
+
} else {
|
|
98
|
+
window.addEventListener('load', scheduleThirdPartyScripts, { once: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Silktide is lazy-loaded: if this click is the first interaction, load it and poll briefly (about 4 s).
|
|
102
|
+
window.openCookiePreferences = function () {
|
|
103
|
+
if (window.silktideConsentManager) {
|
|
104
|
+
window.silktideConsentManager.getInstance().toggleModal(true);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
loadThirdPartyScripts();
|
|
108
|
+
var attempts = 0;
|
|
109
|
+
var poll = setInterval(function () {
|
|
110
|
+
attempts++;
|
|
111
|
+
if (window.silktideConsentManager) {
|
|
112
|
+
clearInterval(poll);
|
|
113
|
+
window.silktideConsentManager.getInstance().toggleModal(true);
|
|
114
|
+
} else if (attempts > 40) {
|
|
115
|
+
clearInterval(poll);
|
|
116
|
+
}
|
|
117
|
+
}, 100);
|
|
118
|
+
};
|
|
119
|
+
})();
|
|
120
|
+
</script>
|
|
121
|
+
)}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The HTML document every site page renders into: head metadata (description, robots, canonical, Open Graph, article,
|
|
4
|
+
* Twitter, title), RSS and favicons, non-critical stylesheets, consent-gated analytics, the Travelpayouts Emerald
|
|
5
|
+
* loader, the skip link and the landmarks (navigation and footer wrapped out of the Pagefind index, `#main-content`).
|
|
6
|
+
* The site's thin Layout supplies identity, theme, fonts, chrome and policy; it keeps its own stylesheet import and
|
|
7
|
+
* its single `<script>` so bundle names stay the site's.
|
|
8
|
+
*
|
|
9
|
+
* Slots: `head-start` (after robots: preconnects), `fonts` (font-face block and preloads), `head` (page additions,
|
|
10
|
+
* JSON-LD), `body-start` (decorative layers), `nav`, `footer`, default (page content).
|
|
11
|
+
*/
|
|
12
|
+
import { normalizePagePath, normalizeSiteUrl, toAbsoluteSiteUrl } from '@sarimarcus/content-sites-core/url';
|
|
13
|
+
import { OgImageMeta } from '../seo/index.ts';
|
|
14
|
+
import EmeraldLoader from '../affiliate/EmeraldLoader.astro';
|
|
15
|
+
import AsyncStylesheet from './AsyncStylesheet.astro';
|
|
16
|
+
import ConsentAnalytics, { type ConsentType } from './ConsentAnalytics.astro';
|
|
17
|
+
import { SILKTIDE_STYLESHEET } from './resources.ts';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
siteName: string;
|
|
23
|
+
/** Absolute origin, e.g. `https://www.example.com`. */
|
|
24
|
+
siteUrl: string;
|
|
25
|
+
canonicalUrl?: string;
|
|
26
|
+
ogImage?: string;
|
|
27
|
+
ogImageAlt?: string;
|
|
28
|
+
ogType?: string;
|
|
29
|
+
ogLocale?: string;
|
|
30
|
+
articleAuthor?: string;
|
|
31
|
+
/** Absolute URL of the author's profile page, resolved by the site. */
|
|
32
|
+
articleAuthorUrl?: string;
|
|
33
|
+
articlePublishedTime?: string;
|
|
34
|
+
articleModifiedTime?: string;
|
|
35
|
+
articleSection?: string;
|
|
36
|
+
noindex?: boolean;
|
|
37
|
+
preloadImage?: string;
|
|
38
|
+
twitterHandle?: string;
|
|
39
|
+
themeColor: string;
|
|
40
|
+
lang?: string;
|
|
41
|
+
rssTitle?: string;
|
|
42
|
+
/** Non-critical stylesheet URLs (`?url` imports), loaded after the title in this order. */
|
|
43
|
+
stylesheets?: string[];
|
|
44
|
+
gtmId: string;
|
|
45
|
+
consentPrompt: string;
|
|
46
|
+
consentTypes: ConsentType[];
|
|
47
|
+
/** Travelpayouts id for the Emerald loader; omit to leave it out. */
|
|
48
|
+
travelpayoutsTrs?: string;
|
|
49
|
+
skipLinkLabel: string;
|
|
50
|
+
mainClass?: string;
|
|
51
|
+
footerClass?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
siteName,
|
|
58
|
+
siteUrl,
|
|
59
|
+
canonicalUrl: providedCanonicalUrl,
|
|
60
|
+
ogImage = '/og-default.jpg',
|
|
61
|
+
ogImageAlt,
|
|
62
|
+
ogType,
|
|
63
|
+
ogLocale = 'en',
|
|
64
|
+
articleAuthor,
|
|
65
|
+
articleAuthorUrl,
|
|
66
|
+
articlePublishedTime,
|
|
67
|
+
articleModifiedTime,
|
|
68
|
+
articleSection,
|
|
69
|
+
noindex = false,
|
|
70
|
+
preloadImage,
|
|
71
|
+
twitterHandle,
|
|
72
|
+
themeColor,
|
|
73
|
+
lang = 'en',
|
|
74
|
+
rssTitle = siteName,
|
|
75
|
+
stylesheets = [],
|
|
76
|
+
gtmId,
|
|
77
|
+
consentPrompt,
|
|
78
|
+
consentTypes,
|
|
79
|
+
travelpayoutsTrs,
|
|
80
|
+
skipLinkLabel,
|
|
81
|
+
mainClass,
|
|
82
|
+
footerClass,
|
|
83
|
+
} = Astro.props;
|
|
84
|
+
|
|
85
|
+
const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, '');
|
|
86
|
+
const canonicalUrl = providedCanonicalUrl
|
|
87
|
+
? normalizeSiteUrl(providedCanonicalUrl, siteUrl, baseUrl)
|
|
88
|
+
: toAbsoluteSiteUrl(normalizePagePath(Astro.url.pathname, baseUrl), siteUrl, baseUrl);
|
|
89
|
+
const imageAlt = ogImageAlt ?? `${title} - ${siteName}`;
|
|
90
|
+
|
|
91
|
+
function normalizeArticleTime(dateStr: string | undefined): string | undefined {
|
|
92
|
+
if (!dateStr) return undefined;
|
|
93
|
+
if (dateStr.includes('T')) return dateStr;
|
|
94
|
+
const date = new Date(`${dateStr}T00:00:00Z`);
|
|
95
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const publishedTime = normalizeArticleTime(articlePublishedTime);
|
|
99
|
+
const modifiedTime = normalizeArticleTime(articleModifiedTime);
|
|
100
|
+
const isArticleMeta = Boolean(articleAuthorUrl || publishedTime || modifiedTime || articleSection);
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
<!doctype html>
|
|
104
|
+
<html lang={lang}>
|
|
105
|
+
<head>
|
|
106
|
+
<meta charset="UTF-8" />
|
|
107
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
108
|
+
<meta name="theme-color" content={themeColor} />
|
|
109
|
+
<meta name="description" content={description} />
|
|
110
|
+
<meta name="robots" content={noindex ? 'noindex, follow' : 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'} />
|
|
111
|
+
<slot name="head-start" />
|
|
112
|
+
|
|
113
|
+
<slot name="fonts" />
|
|
114
|
+
|
|
115
|
+
<link rel="canonical" href={canonicalUrl} />
|
|
116
|
+
<link rel="alternate" type="application/rss+xml" title={rssTitle} href={`${baseUrl}/rss.xml`} />
|
|
117
|
+
<link rel="icon" type="image/svg+xml" href={`${baseUrl}/favicon.svg`} />
|
|
118
|
+
<link rel="icon" type="image/x-icon" href={`${baseUrl}/favicon.ico`} />
|
|
119
|
+
|
|
120
|
+
<AsyncStylesheet href={SILKTIDE_STYLESHEET} />
|
|
121
|
+
|
|
122
|
+
{preloadImage && <link rel="preload" as="image" href={preloadImage} fetchpriority="high" />}
|
|
123
|
+
|
|
124
|
+
<meta property="og:type" content={ogType ?? (isArticleMeta ? 'article' : 'website')} />
|
|
125
|
+
<meta property="og:url" content={canonicalUrl} />
|
|
126
|
+
<meta property="og:title" content={title} />
|
|
127
|
+
<meta property="og:description" content={description} />
|
|
128
|
+
<OgImageMeta image={ogImage} alt={imageAlt} siteUrl={siteUrl} baseUrl={baseUrl} />
|
|
129
|
+
<meta property="og:site_name" content={siteName} />
|
|
130
|
+
<meta property="og:locale" content={ogLocale} />
|
|
131
|
+
{articleAuthor && <meta name="author" content={articleAuthor} />}
|
|
132
|
+
{articleAuthorUrl && <meta property="article:author" content={articleAuthorUrl} />}
|
|
133
|
+
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
|
|
134
|
+
{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
|
|
135
|
+
{articleSection && <meta property="article:section" content={articleSection} />}
|
|
136
|
+
|
|
137
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
138
|
+
{twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
|
|
139
|
+
{twitterHandle && <meta name="twitter:creator" content={twitterHandle} />}
|
|
140
|
+
<meta name="twitter:title" content={title} />
|
|
141
|
+
<meta name="twitter:description" content={description} />
|
|
142
|
+
|
|
143
|
+
<title>{title}</title>
|
|
144
|
+
|
|
145
|
+
{stylesheets.map((href) => <AsyncStylesheet href={href} />)}
|
|
146
|
+
|
|
147
|
+
{travelpayoutsTrs && <EmeraldLoader trs={travelpayoutsTrs} />}
|
|
148
|
+
|
|
149
|
+
<slot name="head" />
|
|
150
|
+
</head>
|
|
151
|
+
<body class="min-h-screen bg-background flex flex-col">
|
|
152
|
+
<ConsentAnalytics gtmId={gtmId} part="noscript" />
|
|
153
|
+
<slot name="body-start" />
|
|
154
|
+
<a
|
|
155
|
+
href="#main-content"
|
|
156
|
+
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
|
|
157
|
+
>
|
|
158
|
+
{skipLinkLabel}
|
|
159
|
+
</a>
|
|
160
|
+
|
|
161
|
+
{Astro.slots.has('nav') && <div data-pagefind-ignore><slot name="nav" /></div>}
|
|
162
|
+
|
|
163
|
+
<div id="main-content" class:list={[mainClass, 'flex-1']}>
|
|
164
|
+
<slot />
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{Astro.slots.has('footer') && <div data-pagefind-ignore class={footerClass}><slot name="footer" /></div>}
|
|
168
|
+
|
|
169
|
+
<ConsentAnalytics gtmId={gtmId} part="script" prompt={consentPrompt} consentTypes={consentTypes} />
|
|
170
|
+
</body>
|
|
171
|
+
</html>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Pushes custom events to the GTM dataLayer.
|
|
2
|
+
// GTM consent mode (Silktide analytics_storage) governs whether GA4 receives each event.
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
dataLayer: Record<string, unknown>[];
|
|
7
|
+
silktideConsentManager?: { hasConsent(id: string): boolean };
|
|
8
|
+
track?: (eventName: string, params?: Record<string, unknown>) => void;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasAnalyticsConsent(): boolean {
|
|
13
|
+
const mgr = window.silktideConsentManager;
|
|
14
|
+
if (!mgr) return true; // not yet loaded — GTM consent mode enforces at send time
|
|
15
|
+
return typeof mgr.hasConsent === 'function' ? mgr.hasConsent('analytics') : true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function track(eventName: string, params: Record<string, unknown> = {}): void {
|
|
19
|
+
if (!hasAnalyticsConsent()) return;
|
|
20
|
+
window.dataLayer = window.dataLayer || [];
|
|
21
|
+
window.dataLayer.push({ event: eventName, page_path: window.location.pathname, ...params });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Page type: the first path segment, 'home' at the root.
|
|
26
|
+
*
|
|
27
|
+
* The single definition behind both the GA4 `page_type` param (runtime pathname, '/plan/x') and a site's GYG `cmp`
|
|
28
|
+
* segment (BUILD-time `Astro.url.pathname`, '/index.html' under build.format 'file'). The `.html`/`index`
|
|
29
|
+
* normalisation is what makes the two agree.
|
|
30
|
+
*/
|
|
31
|
+
export function pageTypeOf(pathname: string): string {
|
|
32
|
+
const first = pathname.split('/').filter(Boolean)[0];
|
|
33
|
+
if (!first) return 'home';
|
|
34
|
+
const bare = first.replace(/\.html$/, '');
|
|
35
|
+
return bare === 'index' || bare === '' ? 'home' : bare;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Param key derived from the data-track-* attribute name where the
|
|
39
|
+
// event-specific prefix (e.g. 'cta_', 'affiliate_', 'link_') has been stripped.
|
|
40
|
+
const HREF_DESTINATION_KEYS: Record<string, string> = {
|
|
41
|
+
outbound_click: 'link_destination',
|
|
42
|
+
cta_click: 'cta_destination',
|
|
43
|
+
internal_link_click: 'link_destination',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Reads data-track-* attributes from the clicked element and fires track().
|
|
47
|
+
// Handles affiliate_click, cta_click, outbound_click, internal_link_click.
|
|
48
|
+
export function setupDelegatedTracking(): void {
|
|
49
|
+
// MPA — pathname is stable for the page's lifetime; compute once.
|
|
50
|
+
const pageType = pageTypeOf(window.location.pathname);
|
|
51
|
+
|
|
52
|
+
document.addEventListener('click', (e: MouseEvent) => {
|
|
53
|
+
const target = e.target;
|
|
54
|
+
if (!(target instanceof Element)) return;
|
|
55
|
+
const el = target.closest<HTMLElement>('[data-track-event]');
|
|
56
|
+
if (!el) return;
|
|
57
|
+
const eventName = el.dataset.trackEvent;
|
|
58
|
+
if (!eventName) return;
|
|
59
|
+
|
|
60
|
+
const params: Record<string, unknown> = { page_type: pageType };
|
|
61
|
+
|
|
62
|
+
for (const key of Object.keys(el.dataset)) {
|
|
63
|
+
if (key === 'trackEvent' || !key.startsWith('track')) continue;
|
|
64
|
+
const param = key
|
|
65
|
+
.slice('track'.length)
|
|
66
|
+
.replace(/([A-Z])/g, '_$1')
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/^_/, '');
|
|
69
|
+
params[param] = el.dataset[key];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fall back to href for the destination param when the markup didn't set it
|
|
73
|
+
// — saves repeating the URL on every CTA/link.
|
|
74
|
+
const destKey = HREF_DESTINATION_KEYS[eventName];
|
|
75
|
+
if (destKey && !params[destKey] && el instanceof HTMLAnchorElement) {
|
|
76
|
+
params[destKey] = el.href;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
track(eventName, params);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fires faq_expand on <details data-faq-track> open. Uses capture because toggle doesn't bubble.
|
|
84
|
+
export function setupFaqTracking(): void {
|
|
85
|
+
document.addEventListener(
|
|
86
|
+
'toggle',
|
|
87
|
+
(e: Event) => {
|
|
88
|
+
const details = e.target as HTMLDetailsElement;
|
|
89
|
+
if (!details.hasAttribute('data-faq-track') || !details.open) return;
|
|
90
|
+
const question = details.querySelector('summary h3')?.textContent?.trim() ?? '';
|
|
91
|
+
track('faq_expand', { question });
|
|
92
|
+
},
|
|
93
|
+
true,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fires scroll_depth at 25/50/75/100% — deduped per page view, removed after 100%.
|
|
98
|
+
// Reads layout-triggering geometry (scrollHeight) inside a rAF-throttled tick
|
|
99
|
+
// so rapid scroll events coalesce to one measurement per frame.
|
|
100
|
+
export function setupScrollDepthTracking(): void {
|
|
101
|
+
const thresholds = [25, 50, 75, 100];
|
|
102
|
+
const fired = new Set<number>();
|
|
103
|
+
let frameQueued = false;
|
|
104
|
+
|
|
105
|
+
function measure() {
|
|
106
|
+
frameQueued = false;
|
|
107
|
+
const total = document.documentElement.scrollHeight;
|
|
108
|
+
if (total <= window.innerHeight) return;
|
|
109
|
+
const pct = Math.round(((window.scrollY + window.innerHeight) / total) * 100);
|
|
110
|
+
for (const d of thresholds) {
|
|
111
|
+
if (pct >= d && !fired.has(d)) {
|
|
112
|
+
fired.add(d);
|
|
113
|
+
track('scroll_depth', { percent: d });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (fired.size === thresholds.length) {
|
|
117
|
+
window.removeEventListener('scroll', onScroll);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function onScroll() {
|
|
122
|
+
if (frameQueued) return;
|
|
123
|
+
frameQueued = true;
|
|
124
|
+
requestAnimationFrame(measure);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface AnalyticsOptions {
|
|
131
|
+
/** Pages that get scroll-depth tracking, tested against `location.pathname`. Omitted or false: none; true: every page. */
|
|
132
|
+
scrollDepth?: boolean | RegExp | ((pathname: string) => boolean);
|
|
133
|
+
/** Track `<details data-faq-track>` opens. */
|
|
134
|
+
faq?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let installed = false;
|
|
138
|
+
|
|
139
|
+
/** Sets `window.track` and binds delegated click, FAQ and scroll-depth tracking. A second call is a no-op. */
|
|
140
|
+
export function setupAnalytics(options: AnalyticsOptions = {}): void {
|
|
141
|
+
if (installed) return;
|
|
142
|
+
installed = true;
|
|
143
|
+
window.track = track;
|
|
144
|
+
setupDelegatedTracking();
|
|
145
|
+
if (options.faq) setupFaqTracking();
|
|
146
|
+
const { scrollDepth } = options;
|
|
147
|
+
const path = window.location.pathname;
|
|
148
|
+
const wantsScroll =
|
|
149
|
+
scrollDepth instanceof RegExp ? scrollDepth.test(path) : typeof scrollDepth === 'function' ? scrollDepth(path) : scrollDepth === true;
|
|
150
|
+
if (wantsScroll) setupScrollDepthTracking();
|
|
151
|
+
}
|
package/src/layout/index.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
// Public surface of @sarimarcus/content-sites-ui/layout.
|
|
2
2
|
export { default as Container } from './Container.astro';
|
|
3
3
|
export { default as BreadcrumbBand } from './BreadcrumbBand.astro';
|
|
4
|
+
export { default as AsyncStylesheet } from './AsyncStylesheet.astro';
|
|
5
|
+
export { default as DocumentShell } from './DocumentShell.astro';
|
|
6
|
+
export { default as ConsentAnalytics } from './ConsentAnalytics.astro';
|
|
7
|
+
export type { ConsentType } from './ConsentAnalytics.astro';
|
|
8
|
+
export { shellResources, GTM_ORIGIN, SILKTIDE_BASE, SILKTIDE_STYLESHEET } from './resources.ts';
|
|
9
|
+
export { track, pageTypeOf, setupDelegatedTracking, setupFaqTracking, setupScrollDepthTracking, setupAnalytics } from './analytics.ts';
|
|
10
|
+
export type { AnalyticsOptions } from './analytics.ts';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The third-party hosts the document shell loads, per CSP directive. Every site renders the shell, so every site's
|
|
2
|
+
// Content-Security-Policy must allow these (scripts/validate-csp-coverage.mjs checks each site's _headers against this
|
|
3
|
+
// list). Plain data and erasable types only: the gate imports this file under Node.
|
|
4
|
+
|
|
5
|
+
import type { CspDirective } from '../affiliate/resources.ts';
|
|
6
|
+
|
|
7
|
+
/** Google Tag Manager: gtm.js and, for visitors without JavaScript, the ns.html iframe. */
|
|
8
|
+
export const GTM_ORIGIN = 'https://www.googletagmanager.com';
|
|
9
|
+
/** Silktide Consent Manager, pinned. */
|
|
10
|
+
export const SILKTIDE_BASE = 'https://cdn.jsdelivr.net/gh/silktide/consent-manager@v2.0.0';
|
|
11
|
+
/** The consent manager's stylesheet; the CSP gate recognises a shell page by it. */
|
|
12
|
+
export const SILKTIDE_STYLESHEET = `${SILKTIDE_BASE}/silktide-consent-manager.css`;
|
|
13
|
+
const SILKTIDE_ORIGIN = new URL(SILKTIDE_BASE).origin;
|
|
14
|
+
|
|
15
|
+
export const shellResources: Partial<Record<CspDirective, readonly string[]>> = {
|
|
16
|
+
'script-src': [GTM_ORIGIN, SILKTIDE_ORIGIN],
|
|
17
|
+
'style-src': [SILKTIDE_ORIGIN],
|
|
18
|
+
'frame-src': [GTM_ORIGIN],
|
|
19
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One link of the mobile panel. `emphasis` paints it in the tone (hub links); the icon is dimmed unless `iconStrong`
|
|
4
|
+
* or `emphasis`. The `leading` slot replaces the icon (a day-count badge, for instance).
|
|
5
|
+
*/
|
|
6
|
+
import { Icon } from '../primitives/index.ts';
|
|
7
|
+
import { TONES } from './tones.ts';
|
|
8
|
+
import type { NavLink, NavTone } from './types.ts';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
link: NavLink;
|
|
12
|
+
tone?: NavTone;
|
|
13
|
+
active?: boolean;
|
|
14
|
+
emphasis?: 'medium' | 'semibold';
|
|
15
|
+
iconStrong?: boolean;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { link, tone = 'primary', active = false, emphasis, iconStrong = Boolean(emphasis), class: className } = Astro.props;
|
|
20
|
+
const t = TONES[tone];
|
|
21
|
+
const WEIGHT = { medium: 'font-medium', semibold: 'font-semibold' } as const;
|
|
22
|
+
const look = emphasis
|
|
23
|
+
? `${WEIGHT[emphasis]} ${t.text} ${t.strongHover}`
|
|
24
|
+
: `text-muted-foreground ${t.hoverText}`;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<a
|
|
28
|
+
href={link.href}
|
|
29
|
+
aria-current={active ? 'page' : undefined}
|
|
30
|
+
class:list={[
|
|
31
|
+
`flex items-center gap-3 px-4 py-2.5 text-sm ${look} hover:bg-muted transition-colors focus-visible:ring-2 ${t.ring} focus-visible:ring-offset-2`,
|
|
32
|
+
active && !emphasis && `${t.text} font-medium`,
|
|
33
|
+
className,
|
|
34
|
+
]}
|
|
35
|
+
>
|
|
36
|
+
<slot name="leading">{link.icon && <Icon name={link.icon} class={`w-4 h-4 ${iconStrong ? t.icon : t.iconDim}`} />}</slot>
|
|
37
|
+
{link.label}
|
|
38
|
+
</a>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* One section of the mobile menu's single-open accordion: a full-width button and the content the site fills
|
|
4
|
+
* through the default slot (its id, from deterministicId, is the button's aria-controls). Opening a section closes
|
|
5
|
+
* the others in the same panel (SiteHeader controller).
|
|
6
|
+
*/
|
|
7
|
+
import { deterministicId } from '@sarimarcus/content-sites-core/dom';
|
|
8
|
+
import { Icon } from '../primitives/index.ts';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
label: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
/** Classes of the leading icon (its colour, typically). */
|
|
14
|
+
iconClass?: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
buttonClass?: string;
|
|
17
|
+
/** Mark the section button as the current page section (aria-current). */
|
|
18
|
+
current?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
label,
|
|
23
|
+
icon,
|
|
24
|
+
iconClass = 'w-4 h-4 text-primary',
|
|
25
|
+
class: className,
|
|
26
|
+
buttonClass = 'flex items-center justify-between w-full px-4 py-3 text-base font-medium text-foreground hover:bg-muted transition-colors cursor-pointer',
|
|
27
|
+
current = false,
|
|
28
|
+
} = Astro.props;
|
|
29
|
+
const contentId = deterministicId('mobile-nav', Astro.url.pathname, label);
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<div class:list={['mobile-section', className]} data-mobile-section>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
class:list={['mobile-section-btn', buttonClass]}
|
|
36
|
+
aria-current={current ? 'page' : undefined}
|
|
37
|
+
aria-expanded="false"
|
|
38
|
+
aria-controls={contentId}
|
|
39
|
+
data-mobile-section-button
|
|
40
|
+
>
|
|
41
|
+
<span class="flex items-center gap-2">
|
|
42
|
+
{icon && <Icon name={icon} class={iconClass} />}
|
|
43
|
+
{label}
|
|
44
|
+
</span>
|
|
45
|
+
<Icon name="chevron-down" class="w-4 h-4 transition-transform section-chevron" />
|
|
46
|
+
</button>
|
|
47
|
+
<div id={contentId} class="mobile-section-content hidden" data-mobile-section-content>
|
|
48
|
+
<slot />
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<style>
|
|
53
|
+
.mobile-section-btn[aria-expanded='true'] :global(.section-chevron) {
|
|
54
|
+
transform: rotate(180deg);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.mobile-section-content {
|
|
58
|
+
display: block;
|
|
59
|
+
padding-bottom: 0.5rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.mobile-section-content.hidden {
|
|
63
|
+
display: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
.mobile-section :global(.section-chevron),
|
|
68
|
+
.mobile-section-content {
|
|
69
|
+
transition: none !important;
|
|
70
|
+
animation: none !important;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A desktop dropdown: the trigger button (aria-expanded/haspopup/controls) and the panel the site fills through the
|
|
4
|
+
* default slot. The panel opens on click, hover (closing after a short delay), ArrowDown and keyboard focus; the
|
|
5
|
+
* SiteHeader controller drives it. `buttonClass` replaces the default trigger styling, which `tone` tints.
|
|
6
|
+
*/
|
|
7
|
+
import { Icon } from '../primitives/index.ts';
|
|
8
|
+
import type { NavTone } from './types.ts';
|
|
9
|
+
import { TONES } from './tones.ts';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Id of the panel, referenced by the button's aria-controls. */
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
icon?: string;
|
|
16
|
+
tone?: NavTone;
|
|
17
|
+
active?: boolean;
|
|
18
|
+
align?: 'left' | 'center' | 'right';
|
|
19
|
+
buttonClass?: string;
|
|
20
|
+
/** Mark the trigger itself as the current page section (aria-current). */
|
|
21
|
+
current?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { id, label, icon, tone = 'primary', active = false, align = 'left', buttonClass, current = false } = Astro.props;
|
|
25
|
+
|
|
26
|
+
const t = TONES[tone];
|
|
27
|
+
const ALIGN = { left: 'left-0', center: 'left-1/2 -translate-x-1/2', right: 'right-0' } as const;
|
|
28
|
+
const triggerClass = buttonClass ?? [
|
|
29
|
+
`flex items-center gap-1.5 px-3 xl:gap-2 xl:px-4 py-2 text-base font-medium rounded-lg transition-colors hover:bg-muted focus-visible:ring-2 ${t.ring} focus-visible:ring-offset-2 cursor-pointer`,
|
|
30
|
+
active ? t.text : `text-muted-foreground ${t.hoverText}`,
|
|
31
|
+
].join(' ');
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<div class="nav-dropdown relative" data-nav-dropdown>
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
class:list={['nav-dropdown-btn', triggerClass]}
|
|
38
|
+
aria-current={current ? 'page' : undefined}
|
|
39
|
+
aria-expanded="false"
|
|
40
|
+
aria-haspopup="true"
|
|
41
|
+
aria-controls={id}
|
|
42
|
+
data-nav-dropdown-button
|
|
43
|
+
>
|
|
44
|
+
{icon && <Icon name={icon} class="hidden xl:block w-4 h-4" />}
|
|
45
|
+
{label}
|
|
46
|
+
<Icon name="chevron-down" class="w-3 h-3 transition-transform dropdown-chevron" />
|
|
47
|
+
</button>
|
|
48
|
+
|
|
49
|
+
<div id={id} class:list={['dropdown-menu absolute top-full mt-0 pt-2', ALIGN[align]]}>
|
|
50
|
+
<slot />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
/* Opened by the controller through aria-expanded, never by :hover, so crossing the gap does not flicker. */
|
|
56
|
+
.dropdown-menu {
|
|
57
|
+
opacity: 0;
|
|
58
|
+
visibility: hidden;
|
|
59
|
+
transform: translateY(4px);
|
|
60
|
+
transition: opacity 200ms, visibility 200ms, transform 200ms;
|
|
61
|
+
pointer-events: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.nav-dropdown-btn[aria-expanded='true'] + .dropdown-menu,
|
|
65
|
+
.nav-dropdown:focus-within .dropdown-menu {
|
|
66
|
+
opacity: 1;
|
|
67
|
+
visibility: visible;
|
|
68
|
+
transform: translateY(0);
|
|
69
|
+
pointer-events: auto;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.nav-dropdown-btn[aria-expanded='true'] :global(.dropdown-chevron),
|
|
73
|
+
.nav-dropdown:focus-within :global(.dropdown-chevron) {
|
|
74
|
+
transform: rotate(180deg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@media (prefers-reduced-motion: reduce) {
|
|
78
|
+
.dropdown-menu,
|
|
79
|
+
.nav-dropdown :global(.dropdown-chevron) {
|
|
80
|
+
transition: none !important;
|
|
81
|
+
animation: none !important;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
</style>
|