@hagicode/hagilight-docusaurus 0.17.1-dev.175.1.77872ad → 0.19.1-dev.184.1.830a4c6

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 CHANGED
@@ -74,7 +74,7 @@ The built-in analytics IDs (`G-EN03FMT2Q4`, 51LA `L6b88a5yK4h2Xnci`, and PostHog
74
74
 
75
75
  An analytics object enables its provider when it contains an ID. Set `enabled` explicitly to override that. `enabled: true` with no ID is a configuration error.
76
76
 
77
- PostHog needs no configuration or consumer project key when registering `hagilightDocusaurus()`. It uses `phc_qiVVihsL925ovhPYNdLJWz4k7aoKVMwYCevgZPE6PER5`, API host `https://us.i.posthog.com`, and SDK defaults `2026-05-30`. Its single inline initialization shares the global head with Google Analytics on every production HTML page, including localized docs, blogs and not-found pages. No PostHog loader is emitted outside production.
77
+ PostHog needs no configuration or consumer project key when registering `hagilightDocusaurus()`. It uses `phc_qiVVihsL925ovhPYNdLJWz4k7aoKVMwYCevgZPE6PER5`, API host `https://t.hagicode.com` (managed proxy), UI host `https://us.posthog.com`, person profiles `identified_only`, and SDK defaults `2026-05-30`. Sites with a strict script or connect policy must allow `t.hagicode.com`. Its single inline initialization shares the global head with Google Analytics on every production HTML page, including localized docs, blogs and not-found pages. No PostHog loader is emitted outside production.
78
78
 
79
79
  | PostHog input | Behavior |
80
80
  | --- | --- |
@@ -84,6 +84,8 @@ PostHog needs no configuration or consumer project key when registering `hagilig
84
84
  | `posthog: { enabled: true, apiKey: 'phc_Custom123' }` | Enabled with the custom key |
85
85
  | `posthog: { enabled: true }` | Configuration error: `apiKey` required |
86
86
  | `posthog: { enabled: false }` | Disabled independently of Google Analytics and 51LA |
87
+ | `posthog: { searchQuery: 'zero-results' }` | Enabled with the default key; search text is sent only for searches with no results (Docusaurus has no PostHog search events, so this has no effect here) |
88
+ | `posthog: { searchQuery: 'off' }` | Enabled with the default key; no search text (the default) |
87
89
 
88
90
  Keys must match `^phc_[A-Za-z0-9]+$`; enabled providers reject missing or malformed keys. Provider options must be objects and `enabled` must be boolean.
89
91
 
@@ -100,7 +102,12 @@ hagilightDocusaurus({
100
102
  }); // all analytics disabled
101
103
  ```
102
104
 
103
- Each provider is independent: disabling Google Analytics and 51LA alone leaves PostHog enabled. Choose one PostHog initialization owner and opt out here if the site already supplies it. The loader initializes once per document, and the supplied SDK defaults own navigation tracking; Hagilight adds no PostHog capture calls or route listeners. The existing analytics client module remains registered only for Google Analytics and 51LA, preserving their navigation behavior.
105
+ `searchQuery` accepts `off` (default) or `zero-results`; any other value fails configuration with an error naming `analytics.posthog.searchQuery`. It never changes whether PostHog is enabled: an object that holds only `searchQuery` counts as omitted, while `{}` still disables it.
106
+
107
+ Docusaurus sends `not_found_viewed` from its 404 page, `code_copied` from its code blocks, and `locale_changed` from the locale dropdown. The interaction observers are installed whenever PostHog is enabled, even when Google Analytics and 51LA are disabled. Platform gaps: there is no `search_used` or `search_result_clicked`, because Docusaurus search is not Pagefind; `article_promotion_impression`, `theme_changed`, `content_width_changed` and `locale_redirected` are Starlight-only or root-transit-only and are not sent here.
108
+
109
+ Each provider is independent: disabling Google Analytics and 51LA alone leaves PostHog enabled. Choose one PostHog initialization owner and opt out here if the site already supplies it. The loader initializes once per document, and the supplied SDK defaults own navigation tracking. The head registers the site context (`site_framework` `docusaurus`, the plugin version, and `site_id` from `links.siteId` or the site URL's hostname) once. On every route update, including the first page load, the plugin refreshes only `locale` and `page_type` through `posthog.register`, with `page_type` derived from the path, so the 404 page reports a path-derived type rather than `other`. The analytics client module is bundled whenever PostHog is enabled, even when Google Analytics and 51LA are disabled, so marked links are captured as `cta_click` without Google Analytics. Google Analytics and 51LA keep their navigation behavior.
110
+
104
111
 
105
112
  ### SEO
106
113
 
@@ -184,3 +191,7 @@ The sitemap check looks at every loaded plugin, so the position of `hagilightDoc
184
191
  ## Demo
185
192
 
186
193
  The demo site for this package lives in the monorepo at `examples/demo-docusaurus-web` and is published to https://docusaurus.hagicode.com/.
194
+
195
+ ### Consent
196
+
197
+ `analytics.consent` (`enabled`, `privacyUrl`, `policyVersion`) mirrors the Starlight option. With consent on, providers leave inert descriptors in the head, the client module mounts the popup and gate, PostHog's click sink installs only after a grant, and the swizzled footer shows the localized "Cookie settings" entry. The popup styles ship in `consent.css`. `enabled: false` restores immediate loading.
package/consent.css ADDED
@@ -0,0 +1,383 @@
1
+ /* Consent popup (hagilight). Same rules as packages/core/CookieConsent.astro; only the tokens differ. */
2
+ /* Tokens follow Docusaurus --ifm-* variables and fall back to light values where a theme omits them. */
3
+ .hagilight-consent {
4
+ --hc-accent: var(--ifm-color-primary, #2563eb);
5
+ --hc-bg: var(--ifm-background-surface-color, #ffffff);
6
+ --hc-text: var(--ifm-font-color-base, #1f2937);
7
+ --hc-muted: var(--ifm-color-emphasis-700, color-mix(in srgb, var(--hc-text) 72%, transparent));
8
+ --hc-border: var(--ifm-color-emphasis-300, #cbd5e1);
9
+ --hc-action-bg: var(--ifm-color-primary-dark, var(--ifm-color-primary, #2563eb));
10
+ --hc-action-fg: var(--ifm-color-white, #ffffff);
11
+ --hc-focus: var(--ifm-color-primary-darker, var(--ifm-color-primary, #2563eb));
12
+ /* Derived: a surface tinted toward the brand, hairlines from the theme border, motion curves. */
13
+ --hc-surface: color-mix(in srgb, var(--hc-accent) 5%, var(--hc-bg));
14
+ --hc-hairline: color-mix(in srgb, var(--hc-border) 70%, transparent);
15
+ --hc-ease: cubic-bezier(0.22, 1, 0.36, 1);
16
+ --hc-spring: cubic-bezier(0.22, 1, 0.36, 1);
17
+
18
+ position: fixed;
19
+ z-index: 45;
20
+ inset-block-end: max(1rem, env(safe-area-inset-bottom, 0px));
21
+ inset-inline-end: max(1rem, env(safe-area-inset-right, 0px));
22
+ box-sizing: border-box;
23
+ width: min(24rem, calc(100vw - 2rem - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)));
24
+ max-height: calc(100dvh - 2rem);
25
+ overflow-y: auto;
26
+ overscroll-behavior: contain;
27
+ scrollbar-width: thin;
28
+ scrollbar-color: color-mix(in srgb, var(--hc-text) 28%, transparent) transparent;
29
+ padding: 1.25rem 1.25rem 0.5rem;
30
+ border: 0;
31
+ border-radius: 0.875rem;
32
+ background: var(--hc-surface);
33
+ color: var(--hc-text);
34
+ /* Elevation is declared once, as a shadow: a tight contact shadow under a long, soft one. */
35
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.08), 0 18px 44px -14px rgb(0 0 0 / 0.4);
36
+ font-size: 0.875rem;
37
+ line-height: 1.5;
38
+ }
39
+
40
+ /* A damped spring where linear() exists (the same curve as the promotion banner), a long ease-out elsewhere. */
41
+ @supports (animation-timing-function: linear(0, 1)) {
42
+ .hagilight-consent {
43
+ --hc-spring: linear(0, 0.066, 0.22, 0.409, 0.597, 0.761, 0.89, 0.983, 1.042, 1.073, 1.083, 1.079, 1.067, 1.052, 1.036, 1.022, 1.011, 1.002, 0.997, 0.994, 0.993, 0.993, 1);
44
+ }
45
+ }
46
+
47
+ /* Below about 36rem the panel attaches to the bottom edge, full width, with its top corners rounded. */
48
+ @media (max-width: 36rem) {
49
+ .hagilight-consent {
50
+ inset-inline: 0;
51
+ inset-block-end: 0;
52
+ width: auto;
53
+ max-width: none;
54
+ max-height: min(85dvh, calc(100dvh - 1rem));
55
+ border-radius: 0.875rem 0.875rem 0 0;
56
+ padding: 1.125rem 1rem max(0.25rem, env(safe-area-inset-bottom, 0px));
57
+ box-shadow: 0 -1px 2px rgb(0 0 0 / 0.06), 0 -14px 36px -14px rgb(0 0 0 / 0.4);
58
+ }
59
+ }
60
+
61
+ .hagilight-consent[hidden],
62
+ .hagilight-consent [hidden] {
63
+ display: none;
64
+ }
65
+
66
+ /* While the panel fades out its buttons no longer take clicks. */
67
+ .hagilight-consent[data-leaving] {
68
+ pointer-events: none;
69
+ }
70
+
71
+ .hagilight-consent ::selection {
72
+ background: color-mix(in srgb, var(--hc-accent) 28%, transparent);
73
+ color: inherit;
74
+ }
75
+
76
+ /* The one authored moment: the panel springs up, then its parts settle in order. */
77
+ .hagilight-consent:not([hidden]) {
78
+ animation: hagilight-consent-rise 0.7s var(--hc-spring) backwards;
79
+ }
80
+
81
+ .hagilight-consent:not([hidden]) .hagilight-consent__title,
82
+ .hagilight-consent:not([hidden]) .hagilight-consent__intro,
83
+ .hagilight-consent:not([hidden]) .hagilight-consent__actions,
84
+ .hagilight-consent:not([hidden]) .hagilight-consent__toggle {
85
+ animation: hagilight-consent-settle 0.55s var(--hc-ease) backwards;
86
+ }
87
+
88
+ .hagilight-consent:not([hidden]) .hagilight-consent__title { animation-delay: 0.1s; }
89
+ .hagilight-consent:not([hidden]) .hagilight-consent__intro { animation-delay: 0.16s; }
90
+ .hagilight-consent:not([hidden]) .hagilight-consent__actions { animation-delay: 0.22s; }
91
+ .hagilight-consent:not([hidden]) .hagilight-consent__toggle { animation-delay: 0.28s; }
92
+
93
+ @keyframes hagilight-consent-rise {
94
+ from { opacity: 0; transform: translateY(1.5rem) scale(0.97); }
95
+ to { opacity: 1; transform: none; }
96
+ }
97
+
98
+ @keyframes hagilight-consent-settle {
99
+ from { opacity: 0; transform: translateY(0.5rem); }
100
+ to { opacity: 1; transform: none; }
101
+ }
102
+
103
+ .hagilight-consent__title {
104
+ margin: 0;
105
+ color: var(--hc-text);
106
+ font-size: 1.0625rem;
107
+ font-weight: 600;
108
+ line-height: 1.3;
109
+ text-wrap: balance;
110
+ }
111
+
112
+ .hagilight-consent__intro {
113
+ margin: 0.375rem 0 0;
114
+ color: var(--hc-muted);
115
+ line-height: 1.55;
116
+ text-wrap: pretty;
117
+ }
118
+
119
+ /* The current choice only shows once there is one, as a small status chip beside the actions. */
120
+ .hagilight-consent__choice {
121
+ display: inline-flex;
122
+ align-items: center;
123
+ gap: 0.4375rem;
124
+ margin: 0.75rem 0 0;
125
+ padding: 0.1875rem 0.625rem 0.1875rem 0.5rem;
126
+ border-radius: 999px;
127
+ background: color-mix(in srgb, var(--hc-text) 7%, transparent);
128
+ color: var(--hc-text);
129
+ font-size: 0.75rem;
130
+ line-height: 1.4;
131
+ }
132
+
133
+ .hagilight-consent[data-state='undecided'] .hagilight-consent__choice {
134
+ display: none;
135
+ }
136
+
137
+ .hagilight-consent__choice::before {
138
+ content: '';
139
+ flex: none;
140
+ width: 0.4375rem;
141
+ height: 0.4375rem;
142
+ border-radius: 50%;
143
+ background: var(--hc-muted);
144
+ }
145
+
146
+ .hagilight-consent[data-state='granted'] .hagilight-consent__choice::before {
147
+ background: var(--hc-accent);
148
+ }
149
+
150
+ .hagilight-consent__actions {
151
+ display: grid;
152
+ grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
153
+ gap: 0.5rem;
154
+ margin-top: 1rem;
155
+ }
156
+
157
+ /* Accept and Reject share every property, so neither reads as the primary choice. Both rest as a tint and fill on hover. */
158
+ .hagilight-consent__button {
159
+ min-height: 2.75rem;
160
+ margin: 0;
161
+ padding: 0.5rem 0.875rem;
162
+ border: 1px solid color-mix(in srgb, var(--hc-action-bg) 55%, transparent);
163
+ border-radius: 0.625rem;
164
+ background: color-mix(in srgb, var(--hc-action-bg) 10%, transparent);
165
+ color: var(--hc-text);
166
+ font: inherit;
167
+ font-size: 0.875rem;
168
+ font-weight: 600;
169
+ line-height: 1.25;
170
+ text-wrap: balance;
171
+ cursor: pointer;
172
+ transition: background-color 0.18s var(--hc-ease), border-color 0.18s var(--hc-ease), color 0.18s var(--hc-ease), transform 0.18s var(--hc-ease);
173
+ }
174
+
175
+ @media (hover: hover) {
176
+ .hagilight-consent__button:hover {
177
+ border-color: var(--hc-action-bg);
178
+ background: var(--hc-action-bg);
179
+ color: var(--hc-action-fg);
180
+ }
181
+ }
182
+
183
+ .hagilight-consent__button:active {
184
+ transform: scale(0.98);
185
+ }
186
+
187
+ /* A full-width disclosure row: the label on the left, a chevron that turns as the details open. */
188
+ .hagilight-consent__toggle {
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: space-between;
192
+ gap: 0.75rem;
193
+ box-sizing: border-box;
194
+ width: 100%;
195
+ min-height: 2.5rem;
196
+ margin: 0.75rem 0 0;
197
+ padding: 0.5rem 0;
198
+ border: 0;
199
+ border-top: 1px solid var(--hc-hairline);
200
+ border-radius: 0.25rem;
201
+ background: none;
202
+ color: var(--hc-muted);
203
+ font: inherit;
204
+ font-size: 0.8125rem;
205
+ font-weight: 500;
206
+ text-align: start;
207
+ cursor: pointer;
208
+ transition: color 0.18s var(--hc-ease);
209
+ }
210
+
211
+ .hagilight-consent__toggle::after {
212
+ content: '';
213
+ flex: none;
214
+ width: 0.4375rem;
215
+ height: 0.4375rem;
216
+ border: solid currentColor;
217
+ border-width: 0 1.5px 1.5px 0;
218
+ transform: translateY(-0.125rem) rotate(45deg);
219
+ transition: transform 0.3s var(--hc-ease);
220
+ }
221
+
222
+ .hagilight-consent__toggle[aria-expanded='true']::after {
223
+ transform: translateY(0.125rem) rotate(225deg);
224
+ }
225
+
226
+ @media (hover: hover) {
227
+ .hagilight-consent__toggle:hover {
228
+ color: var(--hc-text);
229
+ }
230
+ }
231
+
232
+ .hagilight-consent__button:focus-visible,
233
+ .hagilight-consent__toggle:focus-visible,
234
+ .hagilight-consent a:focus-visible {
235
+ outline: 2px solid var(--hc-focus);
236
+ outline-offset: 2px;
237
+ }
238
+
239
+ .hagilight-consent__details {
240
+ box-sizing: border-box;
241
+ margin: 0;
242
+ padding: 0 0 0.25rem;
243
+ }
244
+
245
+ /* Categories are open sections divided by hairlines, not boxes inside a box. */
246
+ .hagilight-consent__category {
247
+ margin: 0;
248
+ padding: 0.875rem 0;
249
+ border-top: 1px solid var(--hc-hairline);
250
+ }
251
+
252
+ .hagilight-consent__category:first-child {
253
+ padding-top: 0.5rem;
254
+ border-top: 0;
255
+ }
256
+
257
+ .hagilight-consent__category h3 {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 0.5rem;
261
+ margin: 0;
262
+ color: var(--hc-text);
263
+ font-size: 0.8125rem;
264
+ font-weight: 600;
265
+ line-height: 1.3;
266
+ }
267
+
268
+ /* A filled dot is on, a hollow one is off; the analytics dot follows the visitor's choice. */
269
+ .hagilight-consent__category h3::before {
270
+ content: '';
271
+ flex: none;
272
+ box-sizing: border-box;
273
+ width: 0.5rem;
274
+ height: 0.5rem;
275
+ border: 1.5px solid var(--hc-muted);
276
+ border-radius: 50%;
277
+ }
278
+
279
+ .hagilight-consent__category--necessary h3::before,
280
+ .hagilight-consent[data-state='granted'] .hagilight-consent__category--analytics h3::before {
281
+ border-color: var(--hc-accent);
282
+ background: var(--hc-accent);
283
+ }
284
+
285
+ .hagilight-consent__category p,
286
+ .hagilight-consent__category ul {
287
+ margin: 0.375rem 0 0;
288
+ padding-inline-start: 1rem;
289
+ }
290
+
291
+ .hagilight-consent__category p,
292
+ .hagilight-consent__withdraw {
293
+ color: var(--hc-muted);
294
+ font-size: 0.8125rem;
295
+ line-height: 1.5;
296
+ text-wrap: pretty;
297
+ }
298
+
299
+ .hagilight-consent ul {
300
+ list-style: none;
301
+ }
302
+
303
+ /* Storage names are code: monospace, with the kind pushed to the far edge. */
304
+ .hagilight-consent li {
305
+ display: flex;
306
+ align-items: baseline;
307
+ justify-content: space-between;
308
+ gap: 0.75rem;
309
+ margin: 0;
310
+ padding: 0.1875rem 0;
311
+ color: var(--hc-muted);
312
+ font-size: 0.75rem;
313
+ }
314
+
315
+ .hagilight-consent li span {
316
+ flex: 0 1 auto;
317
+ text-align: end;
318
+ }
319
+
320
+ .hagilight-consent code {
321
+ color: var(--hc-text);
322
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
323
+ font-size: 0.75rem;
324
+ overflow-wrap: anywhere;
325
+ }
326
+
327
+ .hagilight-consent__withdraw {
328
+ margin: 0;
329
+ padding: 0.875rem 0 0;
330
+ border-top: 1px solid var(--hc-hairline);
331
+ }
332
+
333
+ .hagilight-consent a {
334
+ color: var(--hc-text);
335
+ font-weight: 600;
336
+ text-decoration-thickness: 1px;
337
+ text-underline-offset: 0.2em;
338
+ }
339
+
340
+ .hagilight-consent a:hover {
341
+ text-decoration-thickness: 2px;
342
+ }
343
+
344
+ .hagilight-consent__privacy {
345
+ margin: 0.5rem 0 0.5rem;
346
+ font-size: 0.8125rem;
347
+ }
348
+
349
+ /* Forced colors drop the shadow and the fills, so the panel and the dots get system-colored edges. */
350
+ @media (forced-colors: active) {
351
+ .hagilight-consent {
352
+ border: 1px solid CanvasText;
353
+ }
354
+
355
+ .hagilight-consent__button {
356
+ border-color: ButtonText;
357
+ }
358
+
359
+ .hagilight-consent__category--necessary h3::before,
360
+ .hagilight-consent[data-state='granted'] .hagilight-consent__category--analytics h3::before {
361
+ border-color: CanvasText;
362
+ background: CanvasText;
363
+ }
364
+ }
365
+
366
+ @media (prefers-reduced-motion: reduce) {
367
+ .hagilight-consent,
368
+ .hagilight-consent * {
369
+ animation: none !important;
370
+ transition: none !important;
371
+ }
372
+ }
373
+
374
+ .hagilight-footer__consent {
375
+ margin: 0;
376
+ padding: 0;
377
+ border: 0;
378
+ background: none;
379
+ color: inherit;
380
+ font: inherit;
381
+ text-decoration: underline;
382
+ cursor: pointer;
383
+ }
@@ -1,5 +1,11 @@
1
1
  import type { HtmlTagObject } from '@docusaurus/types';
2
- export declare function posthogHeadTags(apiKey: string): HtmlTagObject[];
2
+ import { type ConsentPopupOptions } from '@hagicode/hagilight-core/consent-markup';
3
+ import { type PostHogInitOptions, type PostHogSiteContext } from '@hagicode/hagilight-core/posthog';
4
+ export declare function posthogHeadTags(apiKey: string, context?: PostHogSiteContext, options?: PostHogInitOptions): HtmlTagObject[];
5
+ /** Inert data for the consent gate: a JSON script that never executes, so no provider loads before a grant. */
6
+ export declare function consentDescriptorTag(provider: string, payload: Record<string, string>): HtmlTagObject;
7
+ /** The popup's config descriptor, read by the client module once. It holds no IDs. */
8
+ export declare function consentConfigTag(config: ConsentPopupOptions): HtmlTagObject;
3
9
  export declare const DEFAULT_GOOGLE_ANALYTICS_ID = "G-EN03FMT2Q4";
4
10
  export declare const DEFAULT_FIFTY_ONE_LA_ID = "L6b88a5yK4h2Xnci";
5
11
  export declare const GOOGLE_ANALYTICS_ID_PATTERN: RegExp;
package/dist/analytics.js CHANGED
@@ -1,6 +1,19 @@
1
+ import { consentDescriptor, CONSENT_DESCRIPTOR_ID } from '@hagicode/hagilight-core/consent-markup';
1
2
  import { posthogInitScript } from '@hagicode/hagilight-core/posthog';
2
- export function posthogHeadTags(apiKey) {
3
- return [{ tagName: 'script', innerHTML: posthogInitScript(apiKey) }];
3
+ export function posthogHeadTags(apiKey, context, options) {
4
+ return [{ tagName: 'script', innerHTML: posthogInitScript(apiKey, context, options) }];
5
+ }
6
+ /** Inert data for the consent gate: a JSON script that never executes, so no provider loads before a grant. */
7
+ export function consentDescriptorTag(provider, payload) {
8
+ return {
9
+ tagName: 'script',
10
+ attributes: { type: 'application/json', 'data-hagilight-analytics': provider },
11
+ innerHTML: JSON.stringify(payload).replace(/</gu, '\\u003c'),
12
+ };
13
+ }
14
+ /** The popup's config descriptor, read by the client module once. It holds no IDs. */
15
+ export function consentConfigTag(config) {
16
+ return { tagName: 'script', attributes: { type: 'application/json', id: CONSENT_DESCRIPTOR_ID }, innerHTML: consentDescriptor(config) };
4
17
  }
5
18
  export const DEFAULT_GOOGLE_ANALYTICS_ID = 'G-EN03FMT2Q4';
6
19
  export const DEFAULT_FIFTY_ONE_LA_ID = 'L6b88a5yK4h2Xnci';
@@ -31,7 +44,7 @@ export function fiftyOneLaHeadTags(siteId) {
31
44
  },
32
45
  {
33
46
  tagName: 'script',
34
- innerHTML: `LA.init(${JSON.stringify({ id: siteId, ck: siteId, autoTrack: true, hashMode: true, screenRecord: true })});`,
47
+ innerHTML: `LA.init(${JSON.stringify({ id: siteId, ck: siteId, autoTrack: true, hashMode: true })});`,
35
48
  },
36
49
  ];
37
50
  }
@@ -1,3 +1,4 @@
1
+ import { type PostHogClient } from '@hagicode/hagilight-core/posthog-events';
1
2
  export interface RouteLocation {
2
3
  pathname: string;
3
4
  search: string;
@@ -18,6 +19,22 @@ export interface RouteReportDeps {
18
19
  schedule: (task: () => void) => void;
19
20
  }
20
21
  export declare function installClickTracking(target?: Pick<EventTarget, 'addEventListener'>): boolean;
22
+ export interface PostHogPageContext {
23
+ /** The document language, sent as `locale` when non-empty. */
24
+ lang: string;
25
+ /** The site base path, for example `/docs/`. */
26
+ base: string;
27
+ }
28
+ /**
29
+ * Refreshes only `locale` and `page_type` as super properties. Route updates are the only time this
30
+ * module calls PostHog, and it never initializes the SDK or captures an event.
31
+ */
32
+ export declare function refreshPostHogPageProperties(update: RouteDidUpdate, posthog: PostHogClient | undefined, page: PostHogPageContext): boolean;
21
33
  export declare function reportPageView(gtag: unknown, page: PageSnapshot): boolean;
22
34
  export declare function handleRouteDidUpdate(update: RouteDidUpdate, deps: RouteReportDeps): void;
35
+ /**
36
+ * Navigation reports wait for the grant when consent is on: the page then carries the consent descriptor, and
37
+ * only a granted visitor may send a page_view. Without consent the page reports as before.
38
+ */
39
+ export declare function navigationReportsAllowed(doc?: Pick<Document, 'getElementById'>): boolean;
23
40
  export declare function onRouteDidUpdate(update: RouteDidUpdate): void;
@@ -1,6 +1,27 @@
1
1
  import { installGaEventTracking } from '@hagicode/hagilight-core/analytics-events';
2
+ import { getConsent } from '@hagicode/hagilight-core/consent';
3
+ import { CONSENT_DESCRIPTOR_ID } from '@hagicode/hagilight-core/consent-markup';
4
+ import { classifyPagePath, installPostHogClickTracking, } from '@hagicode/hagilight-core/posthog-events';
2
5
  export function installClickTracking(target) {
3
- return installGaEventTracking(target);
6
+ const installed = installGaEventTracking(target);
7
+ // The PostHog sink is installed only when the head script initialized PostHog, so a site that disables
8
+ // PostHog gets no PostHog listener even though this module is bundled for Google Analytics.
9
+ if (globalThis.__hagilightPosthogInitialized === true) {
10
+ installPostHogClickTracking(target);
11
+ }
12
+ return installed;
13
+ }
14
+ /**
15
+ * Refreshes only `locale` and `page_type` as super properties. Route updates are the only time this
16
+ * module calls PostHog, and it never initializes the SDK or captures an event.
17
+ */
18
+ export function refreshPostHogPageProperties(update, posthog, page) {
19
+ if (typeof posthog?.register !== 'function')
20
+ return false;
21
+ const pageType = classifyPagePath(update.location.pathname, { base: page.base, locales: page.lang ? [page.lang] : [] });
22
+ const properties = page.lang ? { locale: page.lang, page_type: pageType } : { page_type: pageType };
23
+ posthog.register(properties);
24
+ return true;
4
25
  }
5
26
  export function reportPageView(gtag, page) {
6
27
  if (typeof gtag !== 'function')
@@ -15,8 +36,17 @@ export function handleRouteDidUpdate(update, deps) {
15
36
  reportPageView(deps.getGtag(), deps.readPage());
16
37
  });
17
38
  }
39
+ /**
40
+ * Navigation reports wait for the grant when consent is on: the page then carries the consent descriptor, and
41
+ * only a granted visitor may send a page_view. Without consent the page reports as before.
42
+ */
43
+ export function navigationReportsAllowed(doc = document) {
44
+ if (!doc.getElementById(CONSENT_DESCRIPTOR_ID))
45
+ return true;
46
+ return getConsent().analytics === 'granted';
47
+ }
18
48
  const browserDeps = {
19
- getGtag: () => globalThis.gtag,
49
+ getGtag: () => (navigationReportsAllowed() ? globalThis.gtag : undefined),
20
50
  readPage: () => ({
21
51
  title: document.title,
22
52
  location: window.location.href,
@@ -26,8 +56,20 @@ const browserDeps = {
26
56
  window.setTimeout(task, 0);
27
57
  },
28
58
  };
59
+ function browserPageContext() {
60
+ return {
61
+ lang: document.documentElement.lang,
62
+ base: document.querySelector('meta[name="hagilight-base-path"]')?.getAttribute('content') || '/',
63
+ };
64
+ }
29
65
  export function onRouteDidUpdate(update) {
30
66
  handleRouteDidUpdate(update, browserDeps);
67
+ try {
68
+ refreshPostHogPageProperties(update, globalThis.posthog, browserPageContext());
69
+ }
70
+ catch {
71
+ // Analytics must never break route handling.
72
+ }
31
73
  }
32
74
  if (typeof document !== 'undefined')
33
75
  installClickTracking(document);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import { mountConsent } from '@hagicode/hagilight-core/consent-mount';
2
+ // The head's descriptors and the shared wiring do the work; this module only runs them in the browser.
3
+ if (typeof document !== 'undefined')
4
+ mountConsent(document, window);
@@ -0,0 +1 @@
1
+ export declare function installPostHogInteractionObservers(): boolean;
@@ -0,0 +1,13 @@
1
+ import { installPostHogInteractions } from '@hagicode/hagilight-core/posthog-interactions';
2
+ /*
3
+ * Installs the shared PostHog interaction observers (search, result clicks, code copy, locale links).
4
+ * Runs only when the head script initialized PostHog. Observers are delegated on the document, so
5
+ * Docusaurus client navigation needs no rebinding and this module adds no route listener.
6
+ */
7
+ export function installPostHogInteractionObservers() {
8
+ if (globalThis.__hagilightPosthogInitialized !== true)
9
+ return false;
10
+ return installPostHogInteractions(document);
11
+ }
12
+ if (typeof document !== 'undefined')
13
+ installPostHogInteractionObservers();
package/dist/discovery.js CHANGED
@@ -221,7 +221,7 @@ async function writeSiteMetadataFile(outputs) {
221
221
  fiftyOneLa: production && options.fiftyOneLaId !== undefined,
222
222
  posthog: production && options.posthogApiKey !== undefined,
223
223
  },
224
- features: { seo: options.seo.enabled, promoto: options.promoto },
224
+ features: { seo: options.seo.enabled, promoto: options.promoto, consent: options.consent.enabled },
225
225
  });
226
226
  writeSiteMetadata({
227
227
  outDir,
package/dist/index.js CHANGED
@@ -3,7 +3,8 @@ import { fileURLToPath } from 'node:url';
3
3
  import { resolveBuildInfo } from '@hagicode/hagilight-core/build-info-resolver';
4
4
  import footerAvatarManifest from '@hagicode/hagilight-core/assets/footer/manifest.json' with { type: 'json' };
5
5
  import { randomPoolFromManifest } from '@hagicode/hagilight-core/random-image';
6
- import { fiftyOneLaHeadTags, googleAnalyticsHeadTags, posthogHeadTags } from './analytics.js';
6
+ import { consentConfigTag, consentDescriptorTag, fiftyOneLaHeadTags, googleAnalyticsHeadTags, posthogHeadTags } from './analytics.js';
7
+ import { posthogInitScript } from '@hagicode/hagilight-core/posthog';
7
8
  import { collectFeedItems, feedHeadTags, writeDiscoveryOutputs } from './discovery.js';
8
9
  import { PLUGIN_NAME } from './global-data.js';
9
10
  import { resolveOptions } from './options.js';
@@ -14,15 +15,45 @@ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', impor
14
15
  function packageFile(relativePath) {
15
16
  return fileURLToPath(new URL(relativePath, import.meta.url));
16
17
  }
17
- function analyticsHeadTags(options) {
18
+ function analyticsHeadTags(options, site) {
18
19
  if (process.env.NODE_ENV !== 'production')
19
20
  return [];
21
+ const consent = options.consent.enabled;
22
+ const posthogContext = {
23
+ siteId: options.links.siteId?.trim() || hostnameOf(site.url),
24
+ framework: 'docusaurus',
25
+ version: PACKAGE_VERSION,
26
+ };
20
27
  return [
21
- ...(options.googleAnalyticsId === undefined ? [] : googleAnalyticsHeadTags(options.googleAnalyticsId)),
22
- ...(options.fiftyOneLaId === undefined ? [] : fiftyOneLaHeadTags(options.fiftyOneLaId)),
23
- ...(options.posthogApiKey === undefined ? [] : posthogHeadTags(options.posthogApiKey)),
28
+ // With consent on, each provider leaves inert data for the gate instead of an executable loader.
29
+ ...(options.googleAnalyticsId === undefined ? [] : consent
30
+ ? [consentDescriptorTag('googleAnalytics', { measurementId: options.googleAnalyticsId })]
31
+ : googleAnalyticsHeadTags(options.googleAnalyticsId)),
32
+ ...(options.fiftyOneLaId === undefined ? [] : consent
33
+ ? [consentDescriptorTag('fiftyOneLa', { siteId: options.fiftyOneLaId })]
34
+ : fiftyOneLaHeadTags(options.fiftyOneLaId)),
35
+ ...(options.posthogApiKey === undefined ? [] : [
36
+ // The client reads this to classify routes for `page_type`; it is emitted only with PostHog.
37
+ { tagName: 'meta', attributes: { name: 'hagilight-base-path', content: site.baseUrl } },
38
+ ...(consent
39
+ ? [consentDescriptorTag('posthog', { apiKey: options.posthogApiKey, loader: posthogInitScript(options.posthogApiKey, posthogContext, { searchQuery: options.posthogSearchQuery }) })]
40
+ : posthogHeadTags(options.posthogApiKey, posthogContext, { searchQuery: options.posthogSearchQuery })),
41
+ ]),
42
+ ...(consent ? [consentConfigTag({
43
+ providers: options.consent.providers,
44
+ privacyUrl: options.consent.privacyUrl,
45
+ policyVersion: options.consent.policyVersion,
46
+ })] : []),
24
47
  ];
25
48
  }
49
+ function hostnameOf(url) {
50
+ try {
51
+ return new URL(url).hostname || undefined;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
26
57
  /** Exposes the footer site identifier to the client banner, which tags its call to action with it. */
27
58
  function promotoSiteIdHeadTags(options) {
28
59
  const siteId = options.links.siteId?.trim();
@@ -33,12 +64,17 @@ function promotoSiteIdHeadTags(options) {
33
64
  /** The footer avatar pair in the core package; matched by path so the rule holds in any install layout. */
34
65
  const FOOTER_AVATAR_ASSET = /[\\/]footer[\\/]avatar\.(?:avif|webp)$/u;
35
66
  function clientModules(options) {
36
- const analyticsEnabled = options.googleAnalyticsId !== undefined || options.fiftyOneLaId !== undefined;
67
+ // The analytics client also installs the PostHog click sink, so PostHog alone is enough to bundle it.
68
+ const analyticsEnabled = options.googleAnalyticsId !== undefined || options.fiftyOneLaId !== undefined || options.posthogApiKey !== undefined;
37
69
  const footerAvatarPicker = options.footer && options.footerAvatar.randomAvatar;
38
70
  return [
39
71
  ...(analyticsEnabled ? [packageFile('./client/analytics.js')] : []),
72
+ ...(options.consent.enabled ? [packageFile('./client/consent.js')] : []),
73
+ // Interaction observers are independent of Google Analytics and 51LA, so PostHog alone registers them.
74
+ ...(options.posthogApiKey === undefined ? [] : [packageFile('./client/posthog-interactions.js')]),
40
75
  ...(options.promoto ? [packageFile('./client/promoto.js'), packageFile('../promoto.css')] : []),
41
76
  ...(options.footer ? [packageFile('../styles.css')] : []),
77
+ ...(options.consent.enabled ? [packageFile('../consent.css')] : []),
42
78
  ...(footerAvatarPicker ? [packageFile('./client/random-image.js')] : []),
43
79
  ];
44
80
  }
@@ -55,7 +91,8 @@ function createPlugin(context, options) {
55
91
  ? randomPoolFromManifest(footerAvatarManifest.pool)
56
92
  : undefined,
57
93
  };
58
- const themeEnabled = options.footer || options.seo.enabled;
94
+ // The PostHog 404 wrapper lives in the theme, so PostHog alone also enables the theme path.
95
+ const themeEnabled = options.footer || options.seo.enabled || options.posthogApiKey !== undefined;
59
96
  const site = {
60
97
  title: context.siteConfig.title,
61
98
  tagline: context.siteConfig.tagline,
@@ -109,7 +146,7 @@ function createPlugin(context, options) {
109
146
  injectHtmlTags() {
110
147
  return {
111
148
  headTags: [
112
- ...analyticsHeadTags(options),
149
+ ...analyticsHeadTags(options, site),
113
150
  ...seoHeadTags(options.seo, site),
114
151
  ...feedHeadTags(site, options.rss, feedItems.length),
115
152
  ...promotoSiteIdHeadTags(options),
package/dist/options.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { SiteLinksOptions } from '@hagicode/hagilight-core/links';
2
2
  import { type ResolvedFooterAvatarOptions } from '@hagicode/hagilight-core/footer-avatar';
3
+ import { type ConsentPopupOptions } from '@hagicode/hagilight-core/consent-markup';
3
4
  import { type SiteMetadataOption } from '@hagicode/hagilight-core/site-metadata';
4
5
  export interface FeatureToggle {
5
6
  enabled?: boolean;
@@ -14,10 +15,28 @@ export interface AnalyticsOptions {
14
15
  googleAnalytics?: GoogleAnalyticsOptions;
15
16
  fiftyOneLa?: FiftyOneLaOptions;
16
17
  posthog?: PostHogOptions;
18
+ consent?: ConsentOptions;
19
+ }
20
+ export interface ConsentOptions {
21
+ /** Defaults to on when any analytics provider is enabled. `false` restores immediate loading. */
22
+ enabled?: boolean;
23
+ /** An absolute HTTP(S) URL or a site-relative path. Omitted means the details have no privacy link. */
24
+ privacyUrl?: string;
25
+ /** Defaults to `1`. Changing it asks every visitor again. */
26
+ policyVersion?: string;
27
+ }
28
+ export interface ResolvedConsentOptions {
29
+ enabled: boolean;
30
+ privacyUrl: string | undefined;
31
+ policyVersion: string;
32
+ providers: ConsentPopupOptions['providers'];
17
33
  }
18
34
  export interface PostHogOptions extends FeatureToggle {
19
35
  apiKey?: string;
36
+ /** `zero-results` sends search text only for searches with no results. Defaults to `off`; never changes enablement. */
37
+ searchQuery?: PostHogSearchQueryOption;
20
38
  }
39
+ export type PostHogSearchQueryOption = 'off' | 'zero-results';
21
40
  export interface SeoOrganizationOptions {
22
41
  name?: string;
23
42
  url?: string;
@@ -78,6 +97,8 @@ export interface ResolvedHagilightOptions {
78
97
  googleAnalyticsId: string | undefined;
79
98
  fiftyOneLaId: string | undefined;
80
99
  posthogApiKey: string | undefined;
100
+ consent: ResolvedConsentOptions;
101
+ posthogSearchQuery: PostHogSearchQueryOption;
81
102
  promoto: boolean;
82
103
  languageSelector: boolean;
83
104
  seo: ResolvedSeoOptions;
package/dist/options.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveFooterAvatarOptions } from '@hagicode/hagilight-core/footer-avatar';
2
2
  import { DEFAULT_POSTHOG_API_KEY, POSTHOG_API_KEY_PATTERN } from '@hagicode/hagilight-core/posthog';
3
+ import { validatePolicyVersion, validatePrivacyUrl, } from '@hagicode/hagilight-core/consent-markup';
3
4
  import { isValidSeoImageReference, resolveAbsoluteHttpUrl } from '@hagicode/hagilight-core/seo';
4
5
  import { resolveSiteMetadataOption } from '@hagicode/hagilight-core/site-metadata';
5
6
  import { DEFAULT_FIFTY_ONE_LA_ID, DEFAULT_GOOGLE_ANALYTICS_ID, FIFTY_ONE_LA_ID_PATTERN, GOOGLE_ANALYTICS_ID_PATTERN, } from './analytics.js';
@@ -40,6 +41,26 @@ function featureEnabled(value, path) {
40
41
  return true;
41
42
  return booleanOption(objectOption(value, path).enabled, `${path}.enabled`) ?? true;
42
43
  }
44
+ function resolvePostHogSearchQuery(value) {
45
+ if (value === undefined)
46
+ return 'off';
47
+ const mode = objectOption(value, 'analytics.posthog').searchQuery;
48
+ if (mode === undefined || mode === 'off')
49
+ return 'off';
50
+ if (mode === 'zero-results')
51
+ return mode;
52
+ throw invalid('analytics.posthog.searchQuery', 'must be one of: off, zero-results');
53
+ }
54
+ /**
55
+ * Removes `searchQuery` before provider resolution, because it never changes whether PostHog is enabled.
56
+ * An object that held only `searchQuery` therefore counts as omitted, so the default key stays on.
57
+ */
58
+ function providerOptionsWithoutSearchQuery(value) {
59
+ if (value === undefined || typeof value !== 'object' || value === null || !Object.hasOwn(value, 'searchQuery'))
60
+ return value;
61
+ const { searchQuery: _searchQuery, ...rest } = value;
62
+ return Object.keys(rest).length === 0 ? undefined : rest;
63
+ }
43
64
  function resolveProvider(value, path, idKey, pattern, defaultId) {
44
65
  if (value === undefined)
45
66
  return defaultId;
@@ -94,16 +115,40 @@ function resolveRss(value) {
94
115
  includeBlog: booleanOption(group.includeBlog, 'rss.includeBlog') ?? true,
95
116
  };
96
117
  }
118
+ /**
119
+ * Consent is on by default whenever a provider is enabled; `enabled: false` restores immediate loading. Invalid values
120
+ * throw from the core validators, which name the option.
121
+ */
122
+ function resolveConsent(config, providers) {
123
+ const record = config === undefined ? {} : objectOption(config, 'analytics.consent');
124
+ const requested = booleanOption(record.enabled, 'analytics.consent.enabled');
125
+ return {
126
+ enabled: providers.length > 0 && (requested ?? true),
127
+ privacyUrl: validatePrivacyUrl(record.privacyUrl),
128
+ policyVersion: validatePolicyVersion(record.policyVersion),
129
+ providers,
130
+ };
131
+ }
97
132
  export function resolveOptions(input) {
98
133
  const options = input === undefined ? {} : objectOption(input, 'options');
99
134
  const analytics = options.analytics === undefined ? {} : objectOption(options.analytics, 'analytics');
135
+ const googleAnalyticsId = resolveProvider(analytics.googleAnalytics, 'analytics.googleAnalytics', 'measurementId', GOOGLE_ANALYTICS_ID_PATTERN, DEFAULT_GOOGLE_ANALYTICS_ID);
136
+ const fiftyOneLaId = resolveProvider(analytics.fiftyOneLa, 'analytics.fiftyOneLa', 'siteId', FIFTY_ONE_LA_ID_PATTERN, DEFAULT_FIFTY_ONE_LA_ID);
137
+ const posthogSearchQuery = resolvePostHogSearchQuery(analytics.posthog);
138
+ const posthogApiKey = resolveProvider(providerOptionsWithoutSearchQuery(analytics.posthog), 'analytics.posthog', 'apiKey', POSTHOG_API_KEY_PATTERN, DEFAULT_POSTHOG_API_KEY);
100
139
  return {
101
140
  footer: featureEnabled(options.footer, 'footer'),
102
141
  footerAvatar: resolveFooterAvatar(options.footer),
103
142
  links: options.links === undefined ? {} : objectOption(options.links, 'links'),
104
- googleAnalyticsId: resolveProvider(analytics.googleAnalytics, 'analytics.googleAnalytics', 'measurementId', GOOGLE_ANALYTICS_ID_PATTERN, DEFAULT_GOOGLE_ANALYTICS_ID),
105
- fiftyOneLaId: resolveProvider(analytics.fiftyOneLa, 'analytics.fiftyOneLa', 'siteId', FIFTY_ONE_LA_ID_PATTERN, DEFAULT_FIFTY_ONE_LA_ID),
106
- posthogApiKey: resolveProvider(analytics.posthog, 'analytics.posthog', 'apiKey', POSTHOG_API_KEY_PATTERN, DEFAULT_POSTHOG_API_KEY),
143
+ googleAnalyticsId,
144
+ fiftyOneLaId,
145
+ posthogApiKey,
146
+ posthogSearchQuery,
147
+ consent: resolveConsent(analytics.consent, [
148
+ ...(googleAnalyticsId === undefined ? [] : ['googleAnalytics']),
149
+ ...(fiftyOneLaId === undefined ? [] : ['fiftyOneLa']),
150
+ ...(posthogApiKey === undefined ? [] : ['posthog']),
151
+ ]),
107
152
  promoto: featureEnabled(options.promoto, 'promoto'),
108
153
  languageSelector: featureEnabled(options.languageSelector, 'languageSelector'),
109
154
  seo: resolveSeo(options.seo),
@@ -13,5 +13,11 @@ export interface FooterViewProps {
13
13
  year: number;
14
14
  buildInfo?: BuildInfo;
15
15
  avatar?: FooterAvatar;
16
+ /** Identifies the hosting site in campaign tags; without it the brand links stay untagged. */
17
+ siteId?: string;
18
+ /** The hosting site's URL, used only to recognise same-origin destinations that must stay untagged. */
19
+ siteUrl?: string;
20
+ /** The localized "Cookie settings" label. Set only when the consent popup is mounted. */
21
+ consentLabel?: string;
16
22
  }
17
- export declare function FooterView({ links, version, year, buildInfo, avatar }: FooterViewProps): ReactElement;
23
+ export declare function FooterView({ links, version, year, buildInfo, avatar, siteId, siteUrl, consentLabel }: FooterViewProps): ReactElement;
@@ -5,16 +5,33 @@ import { buildInfoItems } from '@hagicode/hagilight-core/build-info';
5
5
  import { pickFooterBrandLinks, resolveFooterBrand } from '@hagicode/hagilight-core/footer-brand';
6
6
  import { footerAvatarImageAttributes } from '@hagicode/hagilight-core/footer-avatar';
7
7
  import { randomPoolAttribute } from '@hagicode/hagilight-core/random-image';
8
+ import { withCampaignUtm } from '@hagicode/hagilight-core/links';
8
9
  function LinkSection({ heading, navLabel, links, tagged }) {
9
10
  return (_jsxs("section", { children: [_jsx("h2", { children: heading }), _jsx("nav", { "aria-label": navLabel, children: _jsx("ul", { children: links.map((link) => (_jsx("li", { children: _jsx("a", { href: link.href, target: link.target, rel: link.rel, "aria-label": link.ariaLabel, ...(tagged ? siteLinkGaAttributes(link, 'footer') : {}), children: link.label }) }, link.id))) }) })] }));
10
11
  }
11
- function FooterBrand({ locale, links, avatar }) {
12
+ function FooterBrand({ locale, links, avatar, siteId, siteUrl }) {
12
13
  const brand = resolveFooterBrand(locale);
13
14
  const { primary, secondary } = pickFooterBrandLinks(links);
14
- return (_jsxs("section", { className: "hagilight-brand", lang: brand.locale, "aria-labelledby": "hagilight-brand-title", children: [_jsxs("div", { className: "hagilight-brand__lead", children: [_jsxs("div", { className: "hagilight-brand__title", children: [avatar && (_jsxs("picture", { className: "hagilight-brand__avatar", "data-random-fan": avatar.randomPool && randomPoolAttribute(avatar.randomPool), children: [_jsx("source", { type: "image/avif", srcSet: avatar.avif }), _jsx("source", { type: "image/webp", srcSet: avatar.webp }), _jsx("img", { src: avatar.webp, ...footerAvatarImageAttributes() })] })), _jsx("h2", { className: "hagilight-brand__headline", id: "hagilight-brand-title", children: brand.headline })] }), _jsxs("div", { className: "hagilight-brand__body", children: [_jsx("p", { className: "hagilight-brand__summary", children: brand.summary }), (primary || secondary) && (_jsxs("div", { className: "hagilight-brand__actions", children: [primary && (_jsxs("a", { className: "hagilight-brand__primary", href: primary.href, target: primary.target, rel: primary.rel, "aria-label": primary.ariaLabel, ...siteLinkGaAttributes(primary, 'footer_brand'), children: [_jsx("span", { children: primary.label }), _jsx("svg", { className: "hagilight-brand__arrow", viewBox: "0 0 16 16", width: "16", height: "16", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M2.5 8h10M8.5 3.5 13 8l-4.5 4.5", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) })] })), secondary && (_jsx("a", { className: "hagilight-brand__secondary", href: secondary.href, target: secondary.target, rel: secondary.rel, "aria-label": secondary.ariaLabel, ...siteLinkGaAttributes(secondary, 'footer_brand'), children: secondary.label }))] }))] })] }), _jsx("dl", { className: "hagilight-brand__pillars", children: brand.pillars.map((pillar, index) => (_jsxs("div", { className: "hagilight-brand__pillar", "data-pillar": pillar.id, style: { '--hagilight-brand-i': index }, children: [_jsx("dt", { children: pillar.label }), _jsx("dd", { children: pillar.description })] }, pillar.id))) })] }));
15
+ const origin = (() => {
16
+ try {
17
+ return siteUrl ? new URL(siteUrl).origin : undefined;
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ })();
23
+ // Re-tagged from the untagged destination, so the brand block's content reads `footer_brand`.
24
+ const brandHref = (link) => withCampaignUtm(link.untaggedHref ?? link.href, {
25
+ siteId,
26
+ medium: 'footer',
27
+ campaign: link.id,
28
+ content: 'footer_brand',
29
+ origin,
30
+ });
31
+ return (_jsxs("section", { className: "hagilight-brand", lang: brand.locale, "aria-labelledby": "hagilight-brand-title", children: [_jsxs("div", { className: "hagilight-brand__lead", children: [_jsxs("div", { className: "hagilight-brand__title", children: [avatar && (_jsxs("picture", { className: "hagilight-brand__avatar", "data-random-fan": avatar.randomPool && randomPoolAttribute(avatar.randomPool), children: [_jsx("source", { type: "image/avif", srcSet: avatar.avif }), _jsx("source", { type: "image/webp", srcSet: avatar.webp }), _jsx("img", { src: avatar.webp, ...footerAvatarImageAttributes() })] })), _jsx("h2", { className: "hagilight-brand__headline", id: "hagilight-brand-title", children: brand.headline })] }), _jsxs("div", { className: "hagilight-brand__body", children: [_jsx("p", { className: "hagilight-brand__summary", children: brand.summary }), (primary || secondary) && (_jsxs("div", { className: "hagilight-brand__actions", children: [primary && (_jsxs("a", { className: "hagilight-brand__primary", href: brandHref(primary), target: primary.target, rel: primary.rel, "aria-label": primary.ariaLabel, ...siteLinkGaAttributes(primary, 'footer_brand'), children: [_jsx("span", { children: primary.label }), _jsx("svg", { className: "hagilight-brand__arrow", viewBox: "0 0 16 16", width: "16", height: "16", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M2.5 8h10M8.5 3.5 13 8l-4.5 4.5", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) })] })), secondary && (_jsx("a", { className: "hagilight-brand__secondary", href: brandHref(secondary), target: secondary.target, rel: secondary.rel, "aria-label": secondary.ariaLabel, ...siteLinkGaAttributes(secondary, 'footer_brand'), children: secondary.label }))] }))] })] }), _jsx("dl", { className: "hagilight-brand__pillars", children: brand.pillars.map((pillar, index) => (_jsxs("div", { className: "hagilight-brand__pillar", "data-pillar": pillar.id, style: { '--hagilight-brand-i': index }, children: [_jsx("dt", { children: pillar.label }), _jsx("dd", { children: pillar.description })] }, pillar.id))) })] }));
15
32
  }
16
- export function FooterView({ links, version, year, buildInfo, avatar }) {
33
+ export function FooterView({ links, version, year, buildInfo, avatar, siteId, siteUrl, consentLabel }) {
17
34
  const { labels } = links;
18
35
  const buildItems = buildInfoItems(buildInfo);
19
- return (_jsxs("footer", { className: "hagilight-footer", children: [_jsx(FooterBrand, { locale: links.locale, links: links.quick, avatar: avatar }), _jsxs("div", { className: "hagilight-footer__sections", children: [links.relatedSites.length > 0 && (_jsxs("section", { children: [_jsx("h2", { children: labels.relatedSites }), _jsx("nav", { "aria-label": labels.navigation.relatedSites, children: _jsx("ul", { children: links.relatedSites.map((site) => (_jsx("li", { children: _jsx("a", { href: site.href, target: site.target, rel: site.rel, children: site.name }) }, site.id))) }) })] })), _jsx(LinkSection, { heading: labels.quickLinks, navLabel: labels.navigation.quickLinks, links: links.quick, tagged: true }), _jsx(LinkSection, { heading: labels.community, navLabel: labels.navigation.community, links: links.community, tagged: true })] }), links.filings.length > 0 && (_jsx("nav", { className: "hagilight-footer__filings", "aria-label": labels.navigation.filings, children: links.filings.map((link) => (_jsx("a", { href: link.href, target: link.target, rel: link.rel, "aria-label": link.ariaLabel, children: link.label }, link.id))) })), _jsxs("p", { className: "hagilight-footer__copyright", children: [_jsxs("span", { children: ["\u00A9 ", year, " HagiCode"] }), ' | ', _jsxs("span", { className: "hagilight-footer__attribution", children: ["Powered By hagilight-docusaurus@", version] }), buildItems.map((item) => (_jsxs(Fragment, { children: [' | ', _jsxs("span", { className: "hagilight-footer__build", "data-build-field": item.field, title: item.title, "data-build-commit": item.title, children: [item.label, ": ", item.datetime ? _jsx("time", { dateTime: item.datetime, children: item.text }) : item.text] })] }, item.field)))] })] }));
36
+ return (_jsxs("footer", { className: "hagilight-footer", children: [_jsx(FooterBrand, { locale: links.locale, links: links.quick, avatar: avatar, siteId: siteId, siteUrl: siteUrl }), _jsxs("div", { className: "hagilight-footer__sections", children: [links.relatedSites.length > 0 && (_jsxs("section", { children: [_jsx("h2", { children: labels.relatedSites }), _jsx("nav", { "aria-label": labels.navigation.relatedSites, children: _jsx("ul", { children: links.relatedSites.map((site) => (_jsx("li", { children: _jsx("a", { href: site.href, target: site.target, rel: site.rel, children: site.name }) }, site.id))) }) })] })), _jsx(LinkSection, { heading: labels.quickLinks, navLabel: labels.navigation.quickLinks, links: links.quick, tagged: true }), _jsx(LinkSection, { heading: labels.community, navLabel: labels.navigation.community, links: links.community, tagged: true })] }), (links.filings.length > 0 || consentLabel) && (_jsxs("nav", { className: "hagilight-footer__filings", "aria-label": labels.navigation.filings, children: [links.filings.map((link) => (_jsx("a", { href: link.href, target: link.target, rel: link.rel, "aria-label": link.ariaLabel, children: link.label }, link.id))), consentLabel && (_jsx("button", { type: "button", className: "hagilight-footer__consent", "data-consent-preferences": true, children: consentLabel }))] })), _jsxs("p", { className: "hagilight-footer__copyright", children: [_jsxs("span", { children: ["\u00A9 ", year, " HagiCode"] }), ' | ', _jsxs("span", { className: "hagilight-footer__attribution", children: ["Powered By hagilight-docusaurus@", version] }), buildItems.map((item) => (_jsxs(Fragment, { children: [' | ', _jsxs("span", { className: "hagilight-footer__build", "data-build-field": item.field, title: item.title, "data-build-commit": item.title, children: [item.label, ": ", item.datetime ? _jsx("time", { dateTime: item.datetime, children: item.text }) : item.text] })] }, item.field)))] })] }));
20
37
  }
@@ -3,6 +3,7 @@ import FooterDefaults from '@theme-init/Footer';
3
3
  import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
4
4
  import { usePluginData } from '@docusaurus/useGlobalData';
5
5
  import { resolveFooterLinks } from '../../footer.js';
6
+ import { resolveConsentCopy } from '@hagicode/hagilight-core/consent-copy';
6
7
  import { PLUGIN_NAME } from '../../global-data.js';
7
8
  import { FooterView } from './FooterView.js';
8
9
  import avatarAvif from '@hagicode/hagilight-core/assets/footer/avatar.avif';
@@ -21,5 +22,5 @@ export default function Footer() {
21
22
  links: options.links,
22
23
  rssEnabled: options.rss.enabled,
23
24
  });
24
- return (_jsx(FooterView, { links: links, version: version, year: new Date().getFullYear(), buildInfo: buildInfo, avatar: avatar }));
25
+ return (_jsx(FooterView, { links: links, version: version, year: new Date().getFullYear(), buildInfo: buildInfo, avatar: avatar, siteId: options.links.siteId, siteUrl: options.links.siteUrl ?? siteConfig.url, consentLabel: options.consent.enabled ? resolveConsentCopy(i18n.currentLocale).footerEntry : undefined }));
25
26
  }
@@ -0,0 +1,7 @@
1
+ import Content from '@theme-init/NotFound/Content';
2
+ import { type ComponentProps, type ReactElement } from 'react';
3
+ /**
4
+ * The Docusaurus 404 content, with one `not_found_viewed` event per view. The wrapper remounts on each
5
+ * client navigation to the 404 route, so each view reports once. Without PostHog the capture is silent.
6
+ */
7
+ export default function NotFoundContent(props: ComponentProps<typeof Content>): ReactElement;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import Content from '@theme-init/NotFound/Content';
3
+ import { useEffect } from 'react';
4
+ import { capturePostHogEvent, referrerDomain } from '@hagicode/hagilight-core/posthog-events';
5
+ /**
6
+ * The Docusaurus 404 content, with one `not_found_viewed` event per view. The wrapper remounts on each
7
+ * client navigation to the 404 route, so each view reports once. Without PostHog the capture is silent.
8
+ */
9
+ export default function NotFoundContent(props) {
10
+ useEffect(() => {
11
+ try {
12
+ capturePostHogEvent('not_found_viewed', {
13
+ path: window.location.pathname,
14
+ referrer_domain: referrerDomain(document.referrer),
15
+ });
16
+ }
17
+ catch {
18
+ // Analytics must never break the 404 page.
19
+ }
20
+ }, []);
21
+ return _jsx(Content, { ...props });
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-docusaurus",
3
- "version": "0.17.1-dev.175.1.77872ad",
3
+ "version": "0.19.1-dev.184.1.830a4c6",
4
4
  "type": "module",
5
5
  "description": "Docusaurus plugin for HagiCode site features: footer, analytics, promote card, locales, SEO, sitemap, robots.txt, and RSS",
6
6
  "repository": {
@@ -33,12 +33,13 @@
33
33
  "README.md",
34
34
  "styles.css",
35
35
  "promoto.css",
36
+ "consent.css",
36
37
  "dist/**/*.js",
37
38
  "dist/**/*.d.ts"
38
39
  ],
39
40
  "dependencies": {
40
41
  "@docusaurus/types": "^3.9.2",
41
- "@hagicode/hagilight-core": "0.17.1-dev.175.1.77872ad"
42
+ "@hagicode/hagilight-core": "0.19.1-dev.184.1.830a4c6"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "@docusaurus/core": "^3.9.2",