@brandlab-digital/site-next 2.0.0 → 3.0.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.
Files changed (104) hide show
  1. package/dist/analytics.d.ts +111 -52
  2. package/dist/analytics.d.ts.map +1 -1
  3. package/dist/analytics.js +281 -153
  4. package/dist/analytics.js.map +1 -1
  5. package/dist/blog.d.ts +4 -2
  6. package/dist/blog.d.ts.map +1 -1
  7. package/dist/blog.js +7 -7
  8. package/dist/blog.js.map +1 -1
  9. package/dist/contact/client-entry.d.ts +2 -0
  10. package/dist/contact/client-entry.d.ts.map +1 -1
  11. package/dist/contact/client-entry.js +1 -0
  12. package/dist/contact/client-entry.js.map +1 -1
  13. package/dist/contact/client.d.ts +12 -22
  14. package/dist/contact/client.d.ts.map +1 -1
  15. package/dist/contact/client.js +28 -21
  16. package/dist/contact/client.js.map +1 -1
  17. package/dist/contact/form-client.d.ts +148 -0
  18. package/dist/contact/form-client.d.ts.map +1 -0
  19. package/dist/contact/form-client.js +234 -0
  20. package/dist/contact/form-client.js.map +1 -0
  21. package/dist/contact/index.d.ts +1 -1
  22. package/dist/contact/index.d.ts.map +1 -1
  23. package/dist/contact/index.js +1 -1
  24. package/dist/contact/index.js.map +1 -1
  25. package/dist/contact/route-handler.d.ts +32 -0
  26. package/dist/contact/route-handler.d.ts.map +1 -0
  27. package/dist/contact/route-handler.js +44 -0
  28. package/dist/contact/route-handler.js.map +1 -0
  29. package/dist/contact/server-entry.d.ts +1 -0
  30. package/dist/contact/server-entry.d.ts.map +1 -1
  31. package/dist/contact/server-entry.js +1 -0
  32. package/dist/contact/server-entry.js.map +1 -1
  33. package/dist/contact/server.d.ts +5 -0
  34. package/dist/contact/server.d.ts.map +1 -1
  35. package/dist/contact/server.js +13 -7
  36. package/dist/contact/server.js.map +1 -1
  37. package/dist/contact/shared.d.ts +4 -0
  38. package/dist/contact/shared.d.ts.map +1 -1
  39. package/dist/contact/shared.js.map +1 -1
  40. package/dist/contact/testing.d.ts +29 -0
  41. package/dist/contact/testing.d.ts.map +1 -1
  42. package/dist/contact/testing.js +27 -0
  43. package/dist/contact/testing.js.map +1 -1
  44. package/dist/google-reviews-client.d.ts +12 -1
  45. package/dist/google-reviews-client.d.ts.map +1 -1
  46. package/dist/google-reviews-client.js +38 -41
  47. package/dist/google-reviews-client.js.map +1 -1
  48. package/dist/google-reviews-ui.d.ts +4 -2
  49. package/dist/google-reviews-ui.d.ts.map +1 -1
  50. package/dist/google-reviews-ui.js +10 -9
  51. package/dist/google-reviews-ui.js.map +1 -1
  52. package/dist/google-tag-plan.d.ts +1 -4
  53. package/dist/google-tag-plan.d.ts.map +1 -1
  54. package/dist/google-tag-plan.js +1 -14
  55. package/dist/google-tag-plan.js.map +1 -1
  56. package/dist/google-tag.d.ts +5 -4
  57. package/dist/google-tag.d.ts.map +1 -1
  58. package/dist/google-tag.js +5 -4
  59. package/dist/google-tag.js.map +1 -1
  60. package/dist/headers.d.ts +21 -28
  61. package/dist/headers.d.ts.map +1 -1
  62. package/dist/headers.js +62 -51
  63. package/dist/headers.js.map +1 -1
  64. package/dist/instrumentation-client.d.ts +21 -1
  65. package/dist/instrumentation-client.d.ts.map +1 -1
  66. package/dist/instrumentation-client.js +62 -7
  67. package/dist/instrumentation-client.js.map +1 -1
  68. package/dist/lead-registry.d.ts +34 -0
  69. package/dist/lead-registry.d.ts.map +1 -0
  70. package/dist/lead-registry.js +30 -0
  71. package/dist/lead-registry.js.map +1 -0
  72. package/dist/lead-reporting.d.ts +37 -0
  73. package/dist/lead-reporting.d.ts.map +1 -0
  74. package/dist/lead-reporting.js +74 -0
  75. package/dist/lead-reporting.js.map +1 -0
  76. package/dist/metadata.d.ts +10 -11
  77. package/dist/metadata.d.ts.map +1 -1
  78. package/dist/metadata.js +17 -19
  79. package/dist/metadata.js.map +1 -1
  80. package/dist/next-config.d.ts +102 -0
  81. package/dist/next-config.d.ts.map +1 -0
  82. package/dist/next-config.js +140 -0
  83. package/dist/next-config.js.map +1 -0
  84. package/dist/robots.d.ts +14 -6
  85. package/dist/robots.d.ts.map +1 -1
  86. package/dist/robots.js +26 -17
  87. package/dist/robots.js.map +1 -1
  88. package/dist/sitemaps.d.ts +34 -0
  89. package/dist/sitemaps.d.ts.map +1 -1
  90. package/dist/sitemaps.js +66 -1
  91. package/dist/sitemaps.js.map +1 -1
  92. package/dist/testing/server-only.d.ts +8 -0
  93. package/dist/testing/server-only.d.ts.map +1 -0
  94. package/dist/testing/server-only.js +8 -0
  95. package/dist/testing/server-only.js.map +1 -0
  96. package/dist/ui-client.d.ts +1 -12
  97. package/dist/ui-client.d.ts.map +1 -1
  98. package/dist/ui-client.js +0 -17
  99. package/dist/ui-client.js.map +1 -1
  100. package/dist/ui.d.ts +22 -2
  101. package/dist/ui.d.ts.map +1 -1
  102. package/dist/ui.js +67 -6
  103. package/dist/ui.js.map +1 -1
  104. package/package.json +16 -3
package/dist/headers.d.ts CHANGED
@@ -1,20 +1,18 @@
1
1
  /**
2
- * Brandlab security/response headers (Brandlab Project Standard §2).
2
+ * Brandlab security/response headers: one canonical header set for every
3
+ * client site, emitted by `defineBrandlabNextConfig`.
3
4
  *
4
- * One canonical header set for every client site, composed into
5
- * `next.config.ts`:
5
+ * The deployment decides everything environment-specific:
6
+ * - a production build (`next build`, on Vercel or locally) enforces the
7
+ * production Content Security Policy and sends HSTS;
8
+ * - `next dev` sends a report-only development policy without HSTS;
9
+ * - a Vercel Preview additionally sends `noindex, nofollow, noarchive` and
10
+ * `private, no-store`, so previews never reach search results or caches.
6
11
  *
7
- * async headers() {
8
- * return buildBrandlabHeaderRules({
9
- * isProduction,
10
- * profile: 'next-local-service',
11
- * })
12
- * }
13
- *
14
- * The X-Robots-Tag here carries snippet-display controls only; per-page
15
- * indexation stays owned by Metadata.robots (noindex pages override via
16
- * their <meta name="robots">).
12
+ * The production X-Robots-Tag carries snippet-display controls only; per-page
13
+ * indexation stays owned by Metadata.robots.
17
14
  */
15
+ import type { BrandlabDeployment } from '@brandlab-digital/site-core/site';
18
16
  export interface BrandlabHeader {
19
17
  key: string;
20
18
  value: string;
@@ -23,10 +21,10 @@ export interface BrandlabHeaderRule {
23
21
  source: string;
24
22
  headers: BrandlabHeader[];
25
23
  }
26
- export declare const BRANDLAB_NEXT_HEADER_PROFILES: readonly ["next-local-service", "next-root-level"];
27
- export type BrandlabNextHeaderProfile = (typeof BRANDLAB_NEXT_HEADER_PROFILES)[number];
28
24
  export declare const BRANDLAB_DEFAULT_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=()";
29
25
  export declare const BRANDLAB_DEFAULT_X_ROBOTS_TAG = "max-snippet:-1, max-image-preview:large, max-video-preview:-1";
26
+ export declare const BRANDLAB_PREVIEW_X_ROBOTS_TAG = "noindex, nofollow, noarchive";
27
+ export declare const BRANDLAB_PREVIEW_CACHE_CONTROL = "private, no-store, max-age=0";
30
28
  export declare const BRANDLAB_HSTS_VALUE = "max-age=31536000";
31
29
  export interface BrandlabHstsExpansion {
32
30
  includeSubDomains: true;
@@ -39,26 +37,21 @@ export interface BrandlabContentSecurityPolicyExtensions {
39
37
  connectSrc?: readonly string[];
40
38
  frameSrc?: readonly string[];
41
39
  }
42
- export type BrandlabContentSecurityPolicyMode = 'enforce' | 'report-only';
43
40
  export interface BrandlabSecurityHeaderOptions {
44
- isProduction: boolean;
45
- profile: BrandlabNextHeaderProfile;
46
- permissionsPolicy?: string;
47
- /** Pass null to omit the snippet-control header entirely. */
48
- xRobotsTag?: string | null;
49
- extraHeaders?: readonly BrandlabHeader[];
41
+ /** From resolveBrandlabDeployment(process.env). */
42
+ deployment: BrandlabDeployment;
50
43
  hsts?: BrandlabHstsExpansion;
51
- contentSecurityPolicyMode?: BrandlabContentSecurityPolicyMode;
52
44
  /** Reviewed HTTPS origins needed by an approved project integration. */
53
45
  contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions;
46
+ /** Reviewed project exception, for example `geolocation=(self)` for a location feature. */
47
+ permissionsPolicy?: string;
48
+ /** Reviewed project headers; they cannot replace a protected header. */
49
+ extraHeaders?: readonly BrandlabHeader[];
54
50
  }
55
51
  export declare function buildBrandlabContentSecurityPolicy(options: {
56
- isProduction: boolean;
57
- profile: BrandlabNextHeaderProfile;
52
+ deployment: BrandlabDeployment;
58
53
  contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions;
59
54
  }): string;
60
55
  export declare function buildBrandlabSecurityHeaders(options: BrandlabSecurityHeaderOptions): BrandlabHeader[];
61
- export declare function buildBrandlabHeaderRules(options: BrandlabSecurityHeaderOptions & {
62
- source?: string;
63
- }): BrandlabHeaderRule[];
56
+ export declare function buildBrandlabHeaderRules(options: BrandlabSecurityHeaderOptions): BrandlabHeaderRule[];
64
57
  //# sourceMappingURL=headers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,cAAc,EAAE,CAAA;CAC1B;AAED,eAAO,MAAM,6BAA6B,oDAGhC,CAAA;AAEV,MAAM,MAAM,yBAAyB,GAAG,CAAC,OAAO,6BAA6B,CAAC,CAAC,MAAM,CAAC,CAAA;AAEtF,eAAO,MAAM,mCAAmC,6CACJ,CAAA;AAE5C,eAAO,MAAM,6BAA6B,kEACuB,CAAA;AAEjE,eAAO,MAAM,mBAAmB,qBACZ,CAAA;AAEpB,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,IAAI,CAAA;IACvB,OAAO,CAAC,EAAE,IAAI,CAAA;IACd,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,CAAA;CAC1B;AAcD,MAAM,WAAW,uCAAuC;IACtD,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAED,MAAM,MAAM,iCAAiC,GAAG,SAAS,GAAG,aAAa,CAAA;AAEzE,MAAM,WAAW,6BAA6B;IAC5C,YAAY,EAAE,OAAO,CAAA;IACrB,OAAO,EAAE,yBAAyB,CAAA;IAClC,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAA;IACxC,IAAI,CAAC,EAAE,qBAAqB,CAAA;IAC5B,yBAAyB,CAAC,EAAE,iCAAiC,CAAA;IAC7D,wEAAwE;IACxE,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;CAC1E;AAED,wBAAgB,kCAAkC,CAAC,OAAO,EAAE;IAC1D,YAAY,EAAE,OAAO,CAAA;IACrB,OAAO,EAAE,yBAAyB,CAAA;IAClC,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;CAC1E,UAwDA;AAyCD,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,cAAc,EAAE,CAmDlB;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,6BAA6B,GAAG;IAChF,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,GAAG,kBAAkB,EAAE,CAWvB"}
1
+ {"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAEnB,MAAM,kCAAkC,CAAA;AAEzC,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,cAAc,EAAE,CAAA;CAC1B;AAED,eAAO,MAAM,mCAAmC,6CACJ,CAAA;AAE5C,eAAO,MAAM,6BAA6B,kEACuB,CAAA;AAEjE,eAAO,MAAM,6BAA6B,iCAAiC,CAAA;AAE3E,eAAO,MAAM,8BAA8B,iCAAiC,CAAA;AAE5E,eAAO,MAAM,mBAAmB,qBACZ,CAAA;AAEpB,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,IAAI,CAAA;IACvB,OAAO,CAAC,EAAE,IAAI,CAAA;IACd,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,CAAA;CAC1B;AAoBD,MAAM,WAAW,uCAAuC;IACtD,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAED,MAAM,WAAW,6BAA6B;IAC5C,mDAAmD;IACnD,UAAU,EAAE,kBAAkB,CAAA;IAC9B,IAAI,CAAC,EAAE,qBAAqB,CAAA;IAC5B,wEAAwE;IACxE,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;IACzE,2FAA2F;IAC3F,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,wEAAwE;IACxE,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAA;CACzC;AAcD,wBAAgB,kCAAkC,CAAC,OAAO,EAAE;IAC1D,UAAU,EAAE,kBAAkB,CAAA;IAC9B,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;CAC1E,UAsDA;AAiDD,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,cAAc,EAAE,CAmDlB;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,6BAA6B,GAAG,kBAAkB,EAAE,CAOrG"}
package/dist/headers.js CHANGED
@@ -1,29 +1,25 @@
1
1
  /**
2
- * Brandlab security/response headers (Brandlab Project Standard §2).
2
+ * Brandlab security/response headers: one canonical header set for every
3
+ * client site, emitted by `defineBrandlabNextConfig`.
3
4
  *
4
- * One canonical header set for every client site, composed into
5
- * `next.config.ts`:
5
+ * The deployment decides everything environment-specific:
6
+ * - a production build (`next build`, on Vercel or locally) enforces the
7
+ * production Content Security Policy and sends HSTS;
8
+ * - `next dev` sends a report-only development policy without HSTS;
9
+ * - a Vercel Preview additionally sends `noindex, nofollow, noarchive` and
10
+ * `private, no-store`, so previews never reach search results or caches.
6
11
  *
7
- * async headers() {
8
- * return buildBrandlabHeaderRules({
9
- * isProduction,
10
- * profile: 'next-local-service',
11
- * })
12
- * }
13
- *
14
- * The X-Robots-Tag here carries snippet-display controls only; per-page
15
- * indexation stays owned by Metadata.robots (noindex pages override via
16
- * their <meta name="robots">).
12
+ * The production X-Robots-Tag carries snippet-display controls only; per-page
13
+ * indexation stays owned by Metadata.robots.
17
14
  */
18
- export const BRANDLAB_NEXT_HEADER_PROFILES = [
19
- 'next-local-service',
20
- 'next-root-level',
21
- ];
22
15
  export const BRANDLAB_DEFAULT_PERMISSIONS_POLICY = 'camera=(), microphone=(), geolocation=()';
23
16
  export const BRANDLAB_DEFAULT_X_ROBOTS_TAG = 'max-snippet:-1, max-image-preview:large, max-video-preview:-1';
17
+ export const BRANDLAB_PREVIEW_X_ROBOTS_TAG = 'noindex, nofollow, noarchive';
18
+ export const BRANDLAB_PREVIEW_CACHE_CONTROL = 'private, no-store, max-age=0';
24
19
  export const BRANDLAB_HSTS_VALUE = 'max-age=31536000';
25
20
  const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
26
21
  const PROTECTED_HEADER_NAMES = new Set([
22
+ 'cache-control',
27
23
  'content-security-policy',
28
24
  'content-security-policy-report-only',
29
25
  'x-frame-options',
@@ -33,11 +29,23 @@ const PROTECTED_HEADER_NAMES = new Set([
33
29
  'x-robots-tag',
34
30
  'strict-transport-security',
35
31
  ]);
36
- export function buildBrandlabContentSecurityPolicy(options) {
37
- const profile = options.profile;
38
- if (!BRANDLAB_NEXT_HEADER_PROFILES.includes(profile)) {
39
- throw new Error(`Unsupported Brandlab Next header profile "${profile}".`);
32
+ const DEPLOYMENT_ENVIRONMENTS = new Set([
33
+ 'production',
34
+ 'preview',
35
+ 'development',
36
+ ]);
37
+ function assertDeployment(deployment) {
38
+ const candidate = deployment;
39
+ if (!candidate ||
40
+ typeof candidate !== 'object' ||
41
+ !DEPLOYMENT_ENVIRONMENTS.has(candidate.environment) ||
42
+ typeof candidate.productionBuild !== 'boolean') {
43
+ throw new Error('Header rules require the deployment from resolveBrandlabDeployment(process.env).');
40
44
  }
45
+ }
46
+ export function buildBrandlabContentSecurityPolicy(options) {
47
+ assertDeployment(options.deployment);
48
+ const productionBuild = options.deployment.productionBuild;
41
49
  const scriptSources = [
42
50
  "'self'",
43
51
  "'unsafe-inline'",
@@ -49,7 +57,7 @@ export function buildBrandlabContentSecurityPolicy(options) {
49
57
  'https://googleads.g.doubleclick.net',
50
58
  'https://challenges.cloudflare.com',
51
59
  ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.scriptSrc, 'scriptSrc'),
52
- ...(options.isProduction ? [] : ["'unsafe-eval'", 'http://localhost:*']),
60
+ ...(productionBuild ? [] : ["'unsafe-eval'", 'http://localhost:*']),
53
61
  ];
54
62
  const connectSources = [
55
63
  "'self'",
@@ -66,7 +74,7 @@ export function buildBrandlabContentSecurityPolicy(options) {
66
74
  'https://*.analytics.google.com',
67
75
  'https://challenges.cloudflare.com',
68
76
  ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.connectSrc, 'connectSrc'),
69
- ...(options.isProduction ? [] : ['http://localhost:*', 'ws://localhost:*']),
77
+ ...(productionBuild ? [] : ['http://localhost:*', 'ws://localhost:*']),
70
78
  ];
71
79
  const directives = [
72
80
  "default-src 'self'",
@@ -82,7 +90,7 @@ export function buildBrandlabContentSecurityPolicy(options) {
82
90
  `frame-src 'self' https://challenges.cloudflare.com https://www.googletagmanager.com https://www.google.com ${normalizeCspExtensions(options.contentSecurityPolicyExtensions?.frameSrc, 'frameSrc').join(' ')}`.trim(),
83
91
  "manifest-src 'self'",
84
92
  "worker-src 'self' blob:",
85
- ...(options.isProduction ? ['upgrade-insecure-requests'] : []),
93
+ ...(productionBuild ? ['upgrade-insecure-requests'] : []),
86
94
  ];
87
95
  return directives.join('; ');
88
96
  }
@@ -120,15 +128,25 @@ function validateExtraHeaders(headers) {
120
128
  assertHeaderValue(header.value, `extraHeaders[${index}].value`);
121
129
  });
122
130
  }
131
+ const HEADER_OPTION_KEYS = new Set([
132
+ 'deployment',
133
+ 'hsts',
134
+ 'contentSecurityPolicyExtensions',
135
+ 'permissionsPolicy',
136
+ 'extraHeaders',
137
+ ]);
123
138
  export function buildBrandlabSecurityHeaders(options) {
124
- if (typeof options.isProduction !== 'boolean') {
125
- throw new Error('isProduction must be boolean.');
139
+ if (!options || typeof options !== 'object') {
140
+ throw new TypeError('Header options are required.');
141
+ }
142
+ const unsupported = Object.keys(options).filter((key) => !HEADER_OPTION_KEYS.has(key));
143
+ if (unsupported.length > 0) {
144
+ throw new Error(`Header options contain unsupported keys: ${unsupported.join(', ')}. The deployment decides CSP, HSTS and X-Robots-Tag.`);
126
145
  }
127
- const xRobotsTag = options.xRobotsTag === undefined ? BRANDLAB_DEFAULT_X_ROBOTS_TAG : options.xRobotsTag;
146
+ assertDeployment(options.deployment);
147
+ const { environment, productionBuild } = options.deployment;
128
148
  const permissionsPolicy = options.permissionsPolicy ?? BRANDLAB_DEFAULT_PERMISSIONS_POLICY;
129
149
  assertHeaderValue(permissionsPolicy, 'permissionsPolicy');
130
- if (xRobotsTag !== null)
131
- assertHeaderValue(xRobotsTag, 'xRobotsTag');
132
150
  validateExtraHeaders(options.extraHeaders ?? []);
133
151
  if (options.hsts) {
134
152
  assertHeaderValue(options.hsts.readinessEvidence, 'hsts.readinessEvidence');
@@ -139,19 +157,7 @@ export function buildBrandlabSecurityHeaders(options) {
139
157
  const hstsValue = options.hsts
140
158
  ? `${BRANDLAB_HSTS_VALUE}; includeSubDomains${options.hsts.preload ? '; preload' : ''}`
141
159
  : BRANDLAB_HSTS_VALUE;
142
- const cspMode = options.contentSecurityPolicyMode ?? (options.isProduction ? 'enforce' : 'report-only');
143
- if (!new Set(['enforce', 'report-only']).has(cspMode)) {
144
- throw new Error(`Unsupported contentSecurityPolicyMode "${cspMode}".`);
145
- }
146
- if (options.isProduction && cspMode !== 'enforce') {
147
- throw new Error('Production Content Security Policy must use enforce mode.');
148
- }
149
- const cspHeader = [{
150
- key: cspMode === 'enforce'
151
- ? 'Content-Security-Policy'
152
- : 'Content-Security-Policy-Report-Only',
153
- value: buildBrandlabContentSecurityPolicy(options),
154
- }];
160
+ const preview = environment === 'preview';
155
161
  return [
156
162
  { key: 'X-Frame-Options', value: 'DENY' },
157
163
  { key: 'X-Content-Type-Options', value: 'nosniff' },
@@ -160,22 +166,27 @@ export function buildBrandlabSecurityHeaders(options) {
160
166
  key: 'Permissions-Policy',
161
167
  value: permissionsPolicy,
162
168
  },
163
- ...(xRobotsTag ? [{ key: 'X-Robots-Tag', value: xRobotsTag }] : []),
164
- ...(options.isProduction
169
+ {
170
+ key: 'X-Robots-Tag',
171
+ value: preview ? BRANDLAB_PREVIEW_X_ROBOTS_TAG : BRANDLAB_DEFAULT_X_ROBOTS_TAG,
172
+ },
173
+ ...(productionBuild
165
174
  ? [{ key: 'Strict-Transport-Security', value: hstsValue }]
166
175
  : []),
167
- ...cspHeader,
168
- ...(options.extraHeaders ?? []),
176
+ {
177
+ key: productionBuild
178
+ ? 'Content-Security-Policy'
179
+ : 'Content-Security-Policy-Report-Only',
180
+ value: buildBrandlabContentSecurityPolicy(options),
181
+ },
182
+ ...(options.extraHeaders ?? []).map((header) => ({ ...header })),
183
+ ...(preview ? [{ key: 'Cache-Control', value: BRANDLAB_PREVIEW_CACHE_CONTROL }] : []),
169
184
  ];
170
185
  }
171
186
  export function buildBrandlabHeaderRules(options) {
172
- const source = options.source ?? '/:path*';
173
- if (!source.startsWith('/') || /[\u0000-\u001F\u007F]/.test(source)) {
174
- throw new Error('Header-rule source must be a root-relative single-line pattern.');
175
- }
176
187
  return [
177
188
  {
178
- source,
189
+ source: '/:path*',
179
190
  headers: buildBrandlabSecurityHeaders(options),
180
191
  },
181
192
  ];
@@ -1 +1 @@
1
- {"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Brandlab security/response headers (Brandlab Project Standard §2).\n *\n * One canonical header set for every client site, composed into\n * `next.config.ts`:\n *\n * async headers() {\n * return buildBrandlabHeaderRules({\n * isProduction,\n * profile: 'next-local-service',\n * })\n * }\n *\n * The X-Robots-Tag here carries snippet-display controls only; per-page\n * indexation stays owned by Metadata.robots (noindex pages override via\n * their <meta name=\"robots\">).\n */\n\nexport interface BrandlabHeader {\n key: string\n value: string\n}\n\nexport interface BrandlabHeaderRule {\n source: string\n headers: BrandlabHeader[]\n}\n\nexport const BRANDLAB_NEXT_HEADER_PROFILES = [\n 'next-local-service',\n 'next-root-level',\n] as const\n\nexport type BrandlabNextHeaderProfile = (typeof BRANDLAB_NEXT_HEADER_PROFILES)[number]\n\nexport const BRANDLAB_DEFAULT_PERMISSIONS_POLICY =\n 'camera=(), microphone=(), geolocation=()'\n\nexport const BRANDLAB_DEFAULT_X_ROBOTS_TAG =\n 'max-snippet:-1, max-image-preview:large, max-video-preview:-1'\n\nexport const BRANDLAB_HSTS_VALUE =\n 'max-age=31536000'\n\nexport interface BrandlabHstsExpansion {\n includeSubDomains: true\n preload?: true\n /** Reviewed proof that every affected subdomain is HTTPS-ready. */\n readinessEvidence: string\n}\n\nconst HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/\nconst PROTECTED_HEADER_NAMES = new Set([\n 'content-security-policy',\n 'content-security-policy-report-only',\n 'x-frame-options',\n 'x-content-type-options',\n 'referrer-policy',\n 'permissions-policy',\n 'x-robots-tag',\n 'strict-transport-security',\n])\n\nexport interface BrandlabContentSecurityPolicyExtensions {\n scriptSrc?: readonly string[]\n connectSrc?: readonly string[]\n frameSrc?: readonly string[]\n}\n\nexport type BrandlabContentSecurityPolicyMode = 'enforce' | 'report-only'\n\nexport interface BrandlabSecurityHeaderOptions {\n isProduction: boolean\n profile: BrandlabNextHeaderProfile\n permissionsPolicy?: string\n /** Pass null to omit the snippet-control header entirely. */\n xRobotsTag?: string | null\n extraHeaders?: readonly BrandlabHeader[]\n hsts?: BrandlabHstsExpansion\n contentSecurityPolicyMode?: BrandlabContentSecurityPolicyMode\n /** Reviewed HTTPS origins needed by an approved project integration. */\n contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions\n}\n\nexport function buildBrandlabContentSecurityPolicy(options: {\n isProduction: boolean\n profile: BrandlabNextHeaderProfile\n contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions\n}) {\n const profile = options.profile\n if (!BRANDLAB_NEXT_HEADER_PROFILES.includes(profile)) {\n throw new Error(`Unsupported Brandlab Next header profile \"${profile}\".`)\n }\n\n const scriptSources = [\n \"'self'\",\n \"'unsafe-inline'\",\n 'https://www.googletagmanager.com',\n 'https://www.googleadservices.com',\n 'https://www.google.com',\n 'https://www.gstatic.com',\n 'https://pagead2.googlesyndication.com',\n 'https://googleads.g.doubleclick.net',\n 'https://challenges.cloudflare.com',\n ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.scriptSrc, 'scriptSrc'),\n ...(options.isProduction ? [] : [\"'unsafe-eval'\", 'http://localhost:*']),\n ]\n const connectSources = [\n \"'self'\",\n 'https://www.google-analytics.com',\n 'https://*.google-analytics.com',\n 'https://www.googletagmanager.com',\n 'https://www.googleadservices.com',\n 'https://*.doubleclick.net',\n 'https://*.google.com',\n 'https://google.com',\n 'https://www.google.com.au',\n 'https://pagead2.googlesyndication.com',\n 'https://*.googletagmanager.com',\n 'https://*.analytics.google.com',\n 'https://challenges.cloudflare.com',\n ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.connectSrc, 'connectSrc'),\n ...(options.isProduction ? [] : ['http://localhost:*', 'ws://localhost:*']),\n ]\n const directives = [\n \"default-src 'self'\",\n \"base-uri 'self'\",\n \"object-src 'none'\",\n \"frame-ancestors 'none'\",\n \"form-action 'self'\",\n `script-src ${scriptSources.join(' ')}`,\n \"style-src 'self' 'unsafe-inline'\",\n \"font-src 'self' data:\",\n \"img-src 'self' data: blob: https:\",\n `connect-src ${connectSources.join(' ')}`,\n `frame-src 'self' https://challenges.cloudflare.com https://www.googletagmanager.com https://www.google.com ${normalizeCspExtensions(\n options.contentSecurityPolicyExtensions?.frameSrc,\n 'frameSrc',\n ).join(' ')}`.trim(),\n \"manifest-src 'self'\",\n \"worker-src 'self' blob:\",\n ...(options.isProduction ? ['upgrade-insecure-requests'] : []),\n ]\n return directives.join('; ')\n}\n\nfunction normalizeCspExtensions(\n sources: readonly string[] | undefined,\n field: keyof BrandlabContentSecurityPolicyExtensions,\n) {\n if (!sources) return []\n const normalized = sources.map((source) => {\n const value = source.trim().toLowerCase()\n if (!/^https:\\/\\/(?:\\*\\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\\d{1,5})?$/.test(value)) {\n throw new Error(`${field} entries must be HTTPS origins without paths or CSP keywords.`)\n }\n return value\n })\n if (new Set(normalized).size !== normalized.length) {\n throw new Error(`${field} entries must be unique.`)\n }\n return normalized\n}\n\nfunction assertHeaderValue(value: string, field: string) {\n if (!value || value.length > 4_096 || /[\\u0000-\\u001F\\u007F]/.test(value)) {\n throw new Error(`${field} must be a bounded single-line header value.`)\n }\n}\n\nfunction validateExtraHeaders(headers: readonly BrandlabHeader[]) {\n const names = new Set<string>()\n headers.forEach((header, index) => {\n if (!HEADER_NAME_PATTERN.test(header.key)) {\n throw new Error(`extraHeaders[${index}].key is invalid.`)\n }\n const name = header.key.toLowerCase()\n if (PROTECTED_HEADER_NAMES.has(name) || names.has(name)) {\n throw new Error(`extraHeaders[${index}] duplicates a protected or existing header.`)\n }\n names.add(name)\n assertHeaderValue(header.value, `extraHeaders[${index}].value`)\n })\n}\n\nexport function buildBrandlabSecurityHeaders(\n options: BrandlabSecurityHeaderOptions,\n): BrandlabHeader[] {\n if (typeof options.isProduction !== 'boolean') {\n throw new Error('isProduction must be boolean.')\n }\n const xRobotsTag =\n options.xRobotsTag === undefined ? BRANDLAB_DEFAULT_X_ROBOTS_TAG : options.xRobotsTag\n const permissionsPolicy =\n options.permissionsPolicy ?? BRANDLAB_DEFAULT_PERMISSIONS_POLICY\n assertHeaderValue(permissionsPolicy, 'permissionsPolicy')\n if (xRobotsTag !== null) assertHeaderValue(xRobotsTag, 'xRobotsTag')\n validateExtraHeaders(options.extraHeaders ?? [])\n if (options.hsts) {\n assertHeaderValue(options.hsts.readinessEvidence, 'hsts.readinessEvidence')\n if (options.hsts.preload && !options.hsts.includeSubDomains) {\n throw new Error('HSTS preload requires includeSubDomains readiness.')\n }\n }\n const hstsValue = options.hsts\n ? `${BRANDLAB_HSTS_VALUE}; includeSubDomains${options.hsts.preload ? '; preload' : ''}`\n : BRANDLAB_HSTS_VALUE\n const cspMode = options.contentSecurityPolicyMode ?? (\n options.isProduction ? 'enforce' : 'report-only'\n )\n if (!new Set<BrandlabContentSecurityPolicyMode>(['enforce', 'report-only']).has(cspMode)) {\n throw new Error(`Unsupported contentSecurityPolicyMode \"${cspMode}\".`)\n }\n if (options.isProduction && cspMode !== 'enforce') {\n throw new Error('Production Content Security Policy must use enforce mode.')\n }\n const cspHeader = [{\n key: cspMode === 'enforce'\n ? 'Content-Security-Policy'\n : 'Content-Security-Policy-Report-Only',\n value: buildBrandlabContentSecurityPolicy(options),\n }]\n\n return [\n { key: 'X-Frame-Options', value: 'DENY' },\n { key: 'X-Content-Type-Options', value: 'nosniff' },\n { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },\n {\n key: 'Permissions-Policy',\n value: permissionsPolicy,\n },\n ...(xRobotsTag ? [{ key: 'X-Robots-Tag', value: xRobotsTag }] : []),\n ...(options.isProduction\n ? [{ key: 'Strict-Transport-Security', value: hstsValue }]\n : []),\n ...cspHeader,\n ...(options.extraHeaders ?? []),\n ]\n}\n\nexport function buildBrandlabHeaderRules(options: BrandlabSecurityHeaderOptions & {\n source?: string\n}): BrandlabHeaderRule[] {\n const source = options.source ?? '/:path*'\n if (!source.startsWith('/') || /[\\u0000-\\u001F\\u007F]/.test(source)) {\n throw new Error('Header-rule source must be a root-relative single-line pattern.')\n }\n return [\n {\n source,\n headers: buildBrandlabSecurityHeaders(options),\n },\n ]\n}\n"]}
1
+ {"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAiBH,MAAM,CAAC,MAAM,mCAAmC,GAC9C,0CAA0C,CAAA;AAE5C,MAAM,CAAC,MAAM,6BAA6B,GACxC,+DAA+D,CAAA;AAEjE,MAAM,CAAC,MAAM,6BAA6B,GAAG,8BAA8B,CAAA;AAE3E,MAAM,CAAC,MAAM,8BAA8B,GAAG,8BAA8B,CAAA;AAE5E,MAAM,CAAC,MAAM,mBAAmB,GAC9B,kBAAkB,CAAA;AASpB,MAAM,mBAAmB,GAAG,+BAA+B,CAAA;AAC3D,MAAM,sBAAsB,GAAG,IAAI,GAAG,CAAC;IACrC,eAAe;IACf,yBAAyB;IACzB,qCAAqC;IACrC,iBAAiB;IACjB,wBAAwB;IACxB,iBAAiB;IACjB,oBAAoB;IACpB,cAAc;IACd,2BAA2B;CAC5B,CAAC,CAAA;AACF,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAgC;IACrE,YAAY;IACZ,SAAS;IACT,aAAa;CACd,CAAC,CAAA;AAoBF,SAAS,gBAAgB,CAAC,UAAmB;IAC3C,MAAM,SAAS,GAAG,UAAqD,CAAA;IACvE,IACE,CAAC,SAAS;QACV,OAAO,SAAS,KAAK,QAAQ;QAC7B,CAAC,uBAAuB,CAAC,GAAG,CAAC,SAAS,CAAC,WAA4C,CAAC;QACpF,OAAO,SAAS,CAAC,eAAe,KAAK,SAAS,EAC9C,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,kFAAkF,CAAC,CAAA;IACrG,CAAC;AACH,CAAC;AAED,MAAM,UAAU,kCAAkC,CAAC,OAGlD;IACC,gBAAgB,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;IACpC,MAAM,eAAe,GAAG,OAAO,CAAC,UAAU,CAAC,eAAe,CAAA;IAE1D,MAAM,aAAa,GAAG;QACpB,QAAQ;QACR,iBAAiB;QACjB,kCAAkC;QAClC,kCAAkC;QAClC,wBAAwB;QACxB,yBAAyB;QACzB,uCAAuC;QACvC,qCAAqC;QACrC,mCAAmC;QACnC,GAAG,sBAAsB,CAAC,OAAO,CAAC,+BAA+B,EAAE,SAAS,EAAE,WAAW,CAAC;QAC1F,GAAG,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,eAAe,EAAE,oBAAoB,CAAC,CAAC;KACpE,CAAA;IACD,MAAM,cAAc,GAAG;QACrB,QAAQ;QACR,kCAAkC;QAClC,gCAAgC;QAChC,kCAAkC;QAClC,kCAAkC;QAClC,2BAA2B;QAC3B,sBAAsB;QACtB,oBAAoB;QACpB,2BAA2B;QAC3B,uCAAuC;QACvC,gCAAgC;QAChC,gCAAgC;QAChC,mCAAmC;QACnC,GAAG,sBAAsB,CAAC,OAAO,CAAC,+BAA+B,EAAE,UAAU,EAAE,YAAY,CAAC;QAC5F,GAAG,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,oBAAoB,EAAE,kBAAkB,CAAC,CAAC;KACvE,CAAA;IACD,MAAM,UAAU,GAAG;QACjB,oBAAoB;QACpB,iBAAiB;QACjB,mBAAmB;QACnB,wBAAwB;QACxB,oBAAoB;QACpB,cAAc,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE;QACvC,kCAAkC;QAClC,uBAAuB;QACvB,mCAAmC;QACnC,eAAe,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE;QACzC,8GAA8G,sBAAsB,CAClI,OAAO,CAAC,+BAA+B,EAAE,QAAQ,EACjD,UAAU,CACX,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE;QACpB,qBAAqB;QACrB,yBAAyB;QACzB,GAAG,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,2BAA2B,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KAC1D,CAAA;IACD,OAAO,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AAC9B,CAAC;AAED,SAAS,sBAAsB,CAC7B,OAAsC,EACtC,KAAoD;IAEpD,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,CAAA;IACvB,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE;QACxC,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;QACzC,IAAI,CAAC,oEAAoE,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;YACtF,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,+DAA+D,CAAC,CAAA;QAC1F,CAAC;QACD,OAAO,KAAK,CAAA;IACd,CAAC,CAAC,CAAA;IACF,IAAI,IAAI,GAAG,CAAC,UAAU,CAAC,CAAC,IAAI,KAAK,UAAU,CAAC,MAAM,EAAE,CAAC;QACnD,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,0BAA0B,CAAC,CAAA;IACrD,CAAC;IACD,OAAO,UAAU,CAAA;AACnB,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAa,EAAE,KAAa;IACrD,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,KAAK,IAAI,uBAAuB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1E,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,8CAA8C,CAAC,CAAA;IACzE,CAAC;AACH,CAAC;AAED,SAAS,oBAAoB,CAAC,OAAkC;IAC9D,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAA;IAC/B,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE;QAChC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CAAC,gBAAgB,KAAK,mBAAmB,CAAC,CAAA;QAC3D,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,WAAW,EAAE,CAAA;QACrC,IAAI,sBAAsB,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;YACxD,MAAM,IAAI,KAAK,CAAC,gBAAgB,KAAK,8CAA8C,CAAC,CAAA;QACtF,CAAC;QACD,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACf,iBAAiB,CAAC,MAAM,CAAC,KAAK,EAAE,gBAAgB,KAAK,SAAS,CAAC,CAAA;IACjE,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC;IACjC,YAAY;IACZ,MAAM;IACN,iCAAiC;IACjC,mBAAmB;IACnB,cAAc;CACf,CAAC,CAAA;AAEF,MAAM,UAAU,4BAA4B,CAC1C,OAAsC;IAEtC,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QAC5C,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAA;IACrD,CAAC;IACD,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,kBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAA;IACtF,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC3B,MAAM,IAAI,KAAK,CACb,4CAA4C,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,sDAAsD,CACzH,CAAA;IACH,CAAC;IACD,gBAAgB,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;IACpC,MAAM,EAAE,WAAW,EAAE,eAAe,EAAE,GAAG,OAAO,CAAC,UAAU,CAAA;IAC3D,MAAM,iBAAiB,GACrB,OAAO,CAAC,iBAAiB,IAAI,mCAAmC,CAAA;IAClE,iBAAiB,CAAC,iBAAiB,EAAE,mBAAmB,CAAC,CAAA;IACzD,oBAAoB,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,CAAA;IAChD,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;QACjB,iBAAiB,CAAC,OAAO,CAAC,IAAI,CAAC,iBAAiB,EAAE,wBAAwB,CAAC,CAAA;QAC3E,IAAI,OAAO,CAAC,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,iBAAiB,EAAE,CAAC;YAC5D,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAA;QACvE,CAAC;IACH,CAAC;IACD,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI;QAC5B,CAAC,CAAC,GAAG,mBAAmB,sBAAsB,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,EAAE;QACvF,CAAC,CAAC,mBAAmB,CAAA;IACvB,MAAM,OAAO,GAAG,WAAW,KAAK,SAAS,CAAA;IAEzC,OAAO;QACL,EAAE,GAAG,EAAE,iBAAiB,EAAE,KAAK,EAAE,MAAM,EAAE;QACzC,EAAE,GAAG,EAAE,wBAAwB,EAAE,KAAK,EAAE,SAAS,EAAE;QACnD,EAAE,GAAG,EAAE,iBAAiB,EAAE,KAAK,EAAE,iCAAiC,EAAE;QACpE;YACE,GAAG,EAAE,oBAAoB;YACzB,KAAK,EAAE,iBAAiB;SACzB;QACD;YACE,GAAG,EAAE,cAAc;YACnB,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,6BAA6B,CAAC,CAAC,CAAC,6BAA6B;SAC/E;QACD,GAAG,CAAC,eAAe;YACjB,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,2BAA2B,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;YAC1D,CAAC,CAAC,EAAE,CAAC;QACP;YACE,GAAG,EAAE,eAAe;gBAClB,CAAC,CAAC,yBAAyB;gBAC3B,CAAC,CAAC,qCAAqC;YACzC,KAAK,EAAE,kCAAkC,CAAC,OAAO,CAAC;SACnD;QACD,GAAG,CAAC,OAAO,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,CAAC,CAAC;QAChE,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,8BAA8B,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KACtF,CAAA;AACH,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,OAAsC;IAC7E,OAAO;QACL;YACE,MAAM,EAAE,SAAS;YACjB,OAAO,EAAE,4BAA4B,CAAC,OAAO,CAAC;SAC/C;KACF,CAAA;AACH,CAAC","sourcesContent":["/**\n * Brandlab security/response headers: one canonical header set for every\n * client site, emitted by `defineBrandlabNextConfig`.\n *\n * The deployment decides everything environment-specific:\n * - a production build (`next build`, on Vercel or locally) enforces the\n * production Content Security Policy and sends HSTS;\n * - `next dev` sends a report-only development policy without HSTS;\n * - a Vercel Preview additionally sends `noindex, nofollow, noarchive` and\n * `private, no-store`, so previews never reach search results or caches.\n *\n * The production X-Robots-Tag carries snippet-display controls only; per-page\n * indexation stays owned by Metadata.robots.\n */\n\nimport type {\n BrandlabDeployment,\n BrandlabDeploymentEnvironment,\n} from '@brandlab-digital/site-core/site'\n\nexport interface BrandlabHeader {\n key: string\n value: string\n}\n\nexport interface BrandlabHeaderRule {\n source: string\n headers: BrandlabHeader[]\n}\n\nexport const BRANDLAB_DEFAULT_PERMISSIONS_POLICY =\n 'camera=(), microphone=(), geolocation=()'\n\nexport const BRANDLAB_DEFAULT_X_ROBOTS_TAG =\n 'max-snippet:-1, max-image-preview:large, max-video-preview:-1'\n\nexport const BRANDLAB_PREVIEW_X_ROBOTS_TAG = 'noindex, nofollow, noarchive'\n\nexport const BRANDLAB_PREVIEW_CACHE_CONTROL = 'private, no-store, max-age=0'\n\nexport const BRANDLAB_HSTS_VALUE =\n 'max-age=31536000'\n\nexport interface BrandlabHstsExpansion {\n includeSubDomains: true\n preload?: true\n /** Reviewed proof that every affected subdomain is HTTPS-ready. */\n readinessEvidence: string\n}\n\nconst HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/\nconst PROTECTED_HEADER_NAMES = new Set([\n 'cache-control',\n 'content-security-policy',\n 'content-security-policy-report-only',\n 'x-frame-options',\n 'x-content-type-options',\n 'referrer-policy',\n 'permissions-policy',\n 'x-robots-tag',\n 'strict-transport-security',\n])\nconst DEPLOYMENT_ENVIRONMENTS = new Set<BrandlabDeploymentEnvironment>([\n 'production',\n 'preview',\n 'development',\n])\n\nexport interface BrandlabContentSecurityPolicyExtensions {\n scriptSrc?: readonly string[]\n connectSrc?: readonly string[]\n frameSrc?: readonly string[]\n}\n\nexport interface BrandlabSecurityHeaderOptions {\n /** From resolveBrandlabDeployment(process.env). */\n deployment: BrandlabDeployment\n hsts?: BrandlabHstsExpansion\n /** Reviewed HTTPS origins needed by an approved project integration. */\n contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions\n /** Reviewed project exception, for example `geolocation=(self)` for a location feature. */\n permissionsPolicy?: string\n /** Reviewed project headers; they cannot replace a protected header. */\n extraHeaders?: readonly BrandlabHeader[]\n}\n\nfunction assertDeployment(deployment: unknown): asserts deployment is BrandlabDeployment {\n const candidate = deployment as Partial<BrandlabDeployment> | undefined\n if (\n !candidate ||\n typeof candidate !== 'object' ||\n !DEPLOYMENT_ENVIRONMENTS.has(candidate.environment as BrandlabDeploymentEnvironment) ||\n typeof candidate.productionBuild !== 'boolean'\n ) {\n throw new Error('Header rules require the deployment from resolveBrandlabDeployment(process.env).')\n }\n}\n\nexport function buildBrandlabContentSecurityPolicy(options: {\n deployment: BrandlabDeployment\n contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions\n}) {\n assertDeployment(options.deployment)\n const productionBuild = options.deployment.productionBuild\n\n const scriptSources = [\n \"'self'\",\n \"'unsafe-inline'\",\n 'https://www.googletagmanager.com',\n 'https://www.googleadservices.com',\n 'https://www.google.com',\n 'https://www.gstatic.com',\n 'https://pagead2.googlesyndication.com',\n 'https://googleads.g.doubleclick.net',\n 'https://challenges.cloudflare.com',\n ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.scriptSrc, 'scriptSrc'),\n ...(productionBuild ? [] : [\"'unsafe-eval'\", 'http://localhost:*']),\n ]\n const connectSources = [\n \"'self'\",\n 'https://www.google-analytics.com',\n 'https://*.google-analytics.com',\n 'https://www.googletagmanager.com',\n 'https://www.googleadservices.com',\n 'https://*.doubleclick.net',\n 'https://*.google.com',\n 'https://google.com',\n 'https://www.google.com.au',\n 'https://pagead2.googlesyndication.com',\n 'https://*.googletagmanager.com',\n 'https://*.analytics.google.com',\n 'https://challenges.cloudflare.com',\n ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.connectSrc, 'connectSrc'),\n ...(productionBuild ? [] : ['http://localhost:*', 'ws://localhost:*']),\n ]\n const directives = [\n \"default-src 'self'\",\n \"base-uri 'self'\",\n \"object-src 'none'\",\n \"frame-ancestors 'none'\",\n \"form-action 'self'\",\n `script-src ${scriptSources.join(' ')}`,\n \"style-src 'self' 'unsafe-inline'\",\n \"font-src 'self' data:\",\n \"img-src 'self' data: blob: https:\",\n `connect-src ${connectSources.join(' ')}`,\n `frame-src 'self' https://challenges.cloudflare.com https://www.googletagmanager.com https://www.google.com ${normalizeCspExtensions(\n options.contentSecurityPolicyExtensions?.frameSrc,\n 'frameSrc',\n ).join(' ')}`.trim(),\n \"manifest-src 'self'\",\n \"worker-src 'self' blob:\",\n ...(productionBuild ? ['upgrade-insecure-requests'] : []),\n ]\n return directives.join('; ')\n}\n\nfunction normalizeCspExtensions(\n sources: readonly string[] | undefined,\n field: keyof BrandlabContentSecurityPolicyExtensions,\n) {\n if (!sources) return []\n const normalized = sources.map((source) => {\n const value = source.trim().toLowerCase()\n if (!/^https:\\/\\/(?:\\*\\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\\d{1,5})?$/.test(value)) {\n throw new Error(`${field} entries must be HTTPS origins without paths or CSP keywords.`)\n }\n return value\n })\n if (new Set(normalized).size !== normalized.length) {\n throw new Error(`${field} entries must be unique.`)\n }\n return normalized\n}\n\nfunction assertHeaderValue(value: string, field: string) {\n if (!value || value.length > 4_096 || /[\\u0000-\\u001F\\u007F]/.test(value)) {\n throw new Error(`${field} must be a bounded single-line header value.`)\n }\n}\n\nfunction validateExtraHeaders(headers: readonly BrandlabHeader[]) {\n const names = new Set<string>()\n headers.forEach((header, index) => {\n if (!HEADER_NAME_PATTERN.test(header.key)) {\n throw new Error(`extraHeaders[${index}].key is invalid.`)\n }\n const name = header.key.toLowerCase()\n if (PROTECTED_HEADER_NAMES.has(name) || names.has(name)) {\n throw new Error(`extraHeaders[${index}] duplicates a protected or existing header.`)\n }\n names.add(name)\n assertHeaderValue(header.value, `extraHeaders[${index}].value`)\n })\n}\n\nconst HEADER_OPTION_KEYS = new Set([\n 'deployment',\n 'hsts',\n 'contentSecurityPolicyExtensions',\n 'permissionsPolicy',\n 'extraHeaders',\n])\n\nexport function buildBrandlabSecurityHeaders(\n options: BrandlabSecurityHeaderOptions,\n): BrandlabHeader[] {\n if (!options || typeof options !== 'object') {\n throw new TypeError('Header options are required.')\n }\n const unsupported = Object.keys(options).filter((key) => !HEADER_OPTION_KEYS.has(key))\n if (unsupported.length > 0) {\n throw new Error(\n `Header options contain unsupported keys: ${unsupported.join(', ')}. The deployment decides CSP, HSTS and X-Robots-Tag.`,\n )\n }\n assertDeployment(options.deployment)\n const { environment, productionBuild } = options.deployment\n const permissionsPolicy =\n options.permissionsPolicy ?? BRANDLAB_DEFAULT_PERMISSIONS_POLICY\n assertHeaderValue(permissionsPolicy, 'permissionsPolicy')\n validateExtraHeaders(options.extraHeaders ?? [])\n if (options.hsts) {\n assertHeaderValue(options.hsts.readinessEvidence, 'hsts.readinessEvidence')\n if (options.hsts.preload && !options.hsts.includeSubDomains) {\n throw new Error('HSTS preload requires includeSubDomains readiness.')\n }\n }\n const hstsValue = options.hsts\n ? `${BRANDLAB_HSTS_VALUE}; includeSubDomains${options.hsts.preload ? '; preload' : ''}`\n : BRANDLAB_HSTS_VALUE\n const preview = environment === 'preview'\n\n return [\n { key: 'X-Frame-Options', value: 'DENY' },\n { key: 'X-Content-Type-Options', value: 'nosniff' },\n { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },\n {\n key: 'Permissions-Policy',\n value: permissionsPolicy,\n },\n {\n key: 'X-Robots-Tag',\n value: preview ? BRANDLAB_PREVIEW_X_ROBOTS_TAG : BRANDLAB_DEFAULT_X_ROBOTS_TAG,\n },\n ...(productionBuild\n ? [{ key: 'Strict-Transport-Security', value: hstsValue }]\n : []),\n {\n key: productionBuild\n ? 'Content-Security-Policy'\n : 'Content-Security-Policy-Report-Only',\n value: buildBrandlabContentSecurityPolicy(options),\n },\n ...(options.extraHeaders ?? []).map((header) => ({ ...header })),\n ...(preview ? [{ key: 'Cache-Control', value: BRANDLAB_PREVIEW_CACHE_CONTROL }] : []),\n ]\n}\n\nexport function buildBrandlabHeaderRules(options: BrandlabSecurityHeaderOptions): BrandlabHeaderRule[] {\n return [\n {\n source: '/:path*',\n headers: buildBrandlabSecurityHeaders(options),\n },\n ]\n}\n"]}
@@ -1,5 +1,6 @@
1
1
  import { type ContactAttributionSummary } from '@brandlab-digital/site-core/contact';
2
- export type PhoneCallLocation = 'header' | 'footer' | 'hero' | 'cta' | 'fab' | 'contact' | 'location' | 'page';
2
+ import { type BrandlabAnalytics, type PhoneCallPlacement } from './analytics.js';
3
+ export type PhoneCallLocation = PhoneCallPlacement;
3
4
  export interface LastPhoneCallEvent {
4
5
  key: string;
5
6
  at: number;
@@ -17,6 +18,13 @@ export declare function shouldTrackPhoneCallClick(options: {
17
18
  export declare function persistInitialLanding(win?: Window): void;
18
19
  export declare function getInitialLandingPath(win?: Window): string | undefined;
19
20
  export declare function getInitialContactAttribution(win?: Window): ContactAttributionSummary;
21
+ /**
22
+ * Placement of a telephone link: its own `data-phone-location`, then a
23
+ * `<header>` or `<footer>` ancestor, then the nearest ancestor declaring a
24
+ * valid `data-phone-location` (for example a hero or call-to-action section),
25
+ * otherwise `page`. Unrecognised values are ignored, so every tap still
26
+ * produces exactly one event.
27
+ */
20
28
  export declare function getPhoneCallLocation(link: HTMLAnchorElement): PhoneCallLocation;
21
29
  export declare function installPhoneClickListener(options: {
22
30
  trackPhoneLinkClick: (context: {
@@ -27,4 +35,16 @@ export declare function installPhoneClickListener(options: {
27
35
  win?: Window;
28
36
  dedupeWindowMs?: number;
29
37
  }): () => void;
38
+ /**
39
+ * The whole of `src/instrumentation-client.ts`:
40
+ * `installBrandlabInstrumentation(analytics)`. Stores the privacy-safe landing
41
+ * path and visit source for contact attribution, and installs the single
42
+ * telephone-link listener that sends one `phone_link_click` per tap (rapid
43
+ * repeats of the same link are de-duplicated). Installing twice in one window
44
+ * is a no-op; the returned function removes the listener.
45
+ */
46
+ export declare function installBrandlabInstrumentation(analytics: Pick<BrandlabAnalytics, 'trackPhoneLinkClick'>, options?: {
47
+ win?: Window;
48
+ dedupeWindowMs?: number;
49
+ }): () => void;
30
50
  //# sourceMappingURL=instrumentation-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"instrumentation-client.d.ts","sourceRoot":"","sources":["../src/instrumentation-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,yBAAyB,EAC/B,MAAM,qCAAqC,CAAA;AAM5C,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,QAAQ,GACR,MAAM,GACN,KAAK,GACL,KAAK,GACL,SAAS,GACT,UAAU,GACV,MAAM,CAAA;AAEV,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAA;IACX,EAAE,EAAE,MAAM,CAAA;CACX;AAED,eAAO,MAAM,8BAA8B,6BAA6B,CAAA;AACxE,eAAO,MAAM,mCAAmC,OAAO,CAAA;AAEvD,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,UAe9E;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,UAEtE;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE;IACjD,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,kBAAkB,CAAA;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,WAOA;AAED,wBAAgB,qBAAqB,CAAC,GAAG,GAAE,MAAe,QAgCzD;AAED,wBAAgB,qBAAqB,CAAC,GAAG,GAAE,MAAe,sBAOzD;AAED,wBAAgB,4BAA4B,CAAC,GAAG,GAAE,MAAe,GAAG,yBAAyB,CAkB5F;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,iBAAiB,GAAG,iBAAiB,CAU/E;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE;IACjD,mBAAmB,EAAE,CAAC,OAAO,EAAE;QAC7B,QAAQ,EAAE,MAAM,CAAA;QAChB,QAAQ,EAAE,iBAAiB,CAAA;QAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAA;KAC5B,KAAK,IAAI,CAAA;IACV,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,cA4CA"}
1
+ {"version":3,"file":"instrumentation-client.d.ts","sourceRoot":"","sources":["../src/instrumentation-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,yBAAyB,EAC/B,MAAM,qCAAqC,CAAA;AAC5C,OAAO,EAEL,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACxB,MAAM,gBAAgB,CAAA;AAMvB,MAAM,MAAM,iBAAiB,GAAG,kBAAkB,CAAA;AAElD,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAA;IACX,EAAE,EAAE,MAAM,CAAA;CACX;AAED,eAAO,MAAM,8BAA8B,6BAA6B,CAAA;AACxE,eAAO,MAAM,mCAAmC,OAAO,CAAA;AAEvD,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,UAe9E;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,UAEtE;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE;IACjD,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,kBAAkB,CAAA;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,WAOA;AAED,wBAAgB,qBAAqB,CAAC,GAAG,GAAE,MAAe,QAgCzD;AAED,wBAAgB,qBAAqB,CAAC,GAAG,GAAE,MAAe,sBAOzD;AAED,wBAAgB,4BAA4B,CAAC,GAAG,GAAE,MAAe,GAAG,yBAAyB,CAkB5F;AASD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,iBAAiB,GAAG,iBAAiB,CAc/E;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE;IACjD,mBAAmB,EAAE,CAAC,OAAO,EAAE;QAC7B,QAAQ,EAAE,MAAM,CAAA;QAChB,QAAQ,EAAE,iBAAiB,CAAA;QAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAA;KAC5B,KAAK,IAAI,CAAA;IACV,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,cA4CA;AAID;;;;;;;GAOG;AACH,wBAAgB,8BAA8B,CAC5C,SAAS,EAAE,IAAI,CAAC,iBAAiB,EAAE,qBAAqB,CAAC,EACzD,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAO,GACtD,MAAM,IAAI,CA2BZ"}
@@ -1,4 +1,5 @@
1
1
  import { isContactVisitSource, } from '@brandlab-digital/site-core/contact';
2
+ import { PHONE_CALL_PLACEMENTS, } from './analytics.js';
2
3
  import { classifyContactAttribution, normalizeContactAttributionSummary, } from './contact/attribution.js';
3
4
  export const INITIAL_LANDING_STORAGE_PREFIX = 'brandlab.initialLanding.';
4
5
  export const DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS = 1200;
@@ -85,18 +86,32 @@ export function getInitialContactAttribution(win = window) {
85
86
  return { initialLandingPath: fallbackPath, visitSource: 'direct-or-unknown' };
86
87
  }
87
88
  }
89
+ function placementValue(value) {
90
+ const normalized = value?.trim().toLowerCase();
91
+ return PHONE_CALL_PLACEMENTS.includes(normalized)
92
+ ? normalized
93
+ : undefined;
94
+ }
95
+ /**
96
+ * Placement of a telephone link: its own `data-phone-location`, then a
97
+ * `<header>` or `<footer>` ancestor, then the nearest ancestor declaring a
98
+ * valid `data-phone-location` (for example a hero or call-to-action section),
99
+ * otherwise `page`. Unrecognised values are ignored, so every tap still
100
+ * produces exactly one event.
101
+ */
88
102
  export function getPhoneCallLocation(link) {
89
- const explicitLocation = link.dataset.phoneLocation;
90
- if (explicitLocation)
91
- return explicitLocation;
103
+ const explicit = placementValue(link.getAttribute('data-phone-location'));
104
+ if (explicit)
105
+ return explicit;
92
106
  if (link.closest('header'))
93
107
  return 'header';
94
108
  if (link.closest('footer'))
95
109
  return 'footer';
96
- if (link.closest('[data-phone-location="hero"]'))
97
- return 'hero';
98
- if (link.closest('[data-phone-location="cta"]'))
99
- return 'cta';
110
+ for (let ancestor = link.parentElement?.closest('[data-phone-location]'); ancestor; ancestor = ancestor.parentElement?.closest('[data-phone-location]')) {
111
+ const placement = placementValue(ancestor.getAttribute('data-phone-location'));
112
+ if (placement)
113
+ return placement;
114
+ }
100
115
  return 'page';
101
116
  }
102
117
  export function installPhoneClickListener(options) {
@@ -137,4 +152,44 @@ export function installPhoneClickListener(options) {
137
152
  win.document.removeEventListener('click', handlePhoneClick, true);
138
153
  };
139
154
  }
155
+ const installedInstrumentation = new WeakMap();
156
+ /**
157
+ * The whole of `src/instrumentation-client.ts`:
158
+ * `installBrandlabInstrumentation(analytics)`. Stores the privacy-safe landing
159
+ * path and visit source for contact attribution, and installs the single
160
+ * telephone-link listener that sends one `phone_link_click` per tap (rapid
161
+ * repeats of the same link are de-duplicated). Installing twice in one window
162
+ * is a no-op; the returned function removes the listener.
163
+ */
164
+ export function installBrandlabInstrumentation(analytics, options = {}) {
165
+ if (!analytics || typeof analytics.trackPhoneLinkClick !== 'function') {
166
+ throw new TypeError('installBrandlabInstrumentation requires the defineBrandlabAnalytics binding.');
167
+ }
168
+ const win = options.win ?? (typeof window === 'undefined' ? undefined : window);
169
+ if (!win)
170
+ return () => undefined;
171
+ const existing = installedInstrumentation.get(win);
172
+ if (existing)
173
+ return existing;
174
+ persistInitialLanding(win);
175
+ const removeListener = installPhoneClickListener({
176
+ win,
177
+ ...(options.dedupeWindowMs !== undefined ? { dedupeWindowMs: options.dedupeWindowMs } : {}),
178
+ trackPhoneLinkClick: ({ pagePath, location }) => {
179
+ try {
180
+ analytics.trackPhoneLinkClick({ pagePath, placement: location });
181
+ }
182
+ catch {
183
+ // Tracking must never block the telephone action.
184
+ }
185
+ },
186
+ });
187
+ const teardown = () => {
188
+ removeListener();
189
+ if (installedInstrumentation.get(win) === teardown)
190
+ installedInstrumentation.delete(win);
191
+ };
192
+ installedInstrumentation.set(win, teardown);
193
+ return teardown;
194
+ }
140
195
  //# sourceMappingURL=instrumentation-client.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"instrumentation-client.js","sourceRoot":"","sources":["../src/instrumentation-client.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n isContactVisitSource,\n type ContactAttributionSummary,\n} from '@brandlab-digital/site-core/contact'\nimport {\n classifyContactAttribution,\n normalizeContactAttributionSummary,\n} from './contact/attribution.js'\n\nexport type PhoneCallLocation =\n | 'header'\n | 'footer'\n | 'hero'\n | 'cta'\n | 'fab'\n | 'contact'\n | 'location'\n | 'page'\n\nexport interface LastPhoneCallEvent {\n key: string\n at: number\n}\n\nexport const INITIAL_LANDING_STORAGE_PREFIX = 'brandlab.initialLanding.'\nexport const DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS = 1200\n\nexport function normalizeInitialLandingPath(pathname: string | null | undefined) {\n const trimmed = pathname?.trim() || '/'\n if (\n !trimmed.startsWith('/') ||\n trimmed.startsWith('//') ||\n trimmed.includes('\\\\') ||\n /%(?:2f|5c)/i.test(trimmed) ||\n /[\\u0000-\\u001f\\u007f]/.test(trimmed) ||\n trimmed.includes('?') ||\n trimmed.includes('#')\n ) {\n return '/'\n }\n const normalized = trimmed.replace(/\\/{2,}/g, '/')\n return normalized === '/' ? '/' : normalized.replace(/\\/+$/, '')\n}\n\nexport function createPhoneCallDedupeKey(pagePath: string, href: string) {\n return `${pagePath}|${href}`\n}\n\nexport function shouldTrackPhoneCallClick(options: {\n key: string\n now: number\n lastEvent?: LastPhoneCallEvent\n dedupeWindowMs?: number\n}) {\n const windowMs = options.dedupeWindowMs ?? DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS\n return !(\n options.lastEvent &&\n options.lastEvent.key === options.key &&\n options.now - options.lastEvent.at < windowMs\n )\n}\n\nexport function persistInitialLanding(win: Window = window) {\n try {\n const pathKey = `${INITIAL_LANDING_STORAGE_PREFIX}path`\n const sourceKey = `${INITIAL_LANDING_STORAGE_PREFIX}source`\n const detailKey = `${INITIAL_LANDING_STORAGE_PREFIX}sourceDetail`\n const existingPath = win.sessionStorage.getItem(pathKey)\n const safePath = normalizeInitialLandingPath(existingPath ?? win.location.pathname)\n if (existingPath !== safePath) {\n win.sessionStorage.setItem(pathKey, safePath)\n }\n const existingSource = win.sessionStorage.getItem(sourceKey)\n if (!isContactVisitSource(existingSource)) {\n const attribution = classifyContactAttribution({\n initialLandingPath: safePath,\n search: win.location.search,\n referrer: win.document.referrer,\n siteOrigin: win.location.origin,\n })\n win.sessionStorage.setItem(sourceKey, attribution.visitSource)\n if (attribution.visitSourceDetail) {\n win.sessionStorage.setItem(detailKey, attribution.visitSourceDetail)\n } else {\n win.sessionStorage.removeItem(detailKey)\n }\n }\n // Remove values written by the pre-RC privacy contract. Query strings and\n // referrers are not needed for canonical lead measurement.\n win.sessionStorage.removeItem(`${INITIAL_LANDING_STORAGE_PREFIX}url`)\n win.sessionStorage.removeItem(`${INITIAL_LANDING_STORAGE_PREFIX}referrer`)\n } catch {\n // Storage failures must not affect navigation or lead tracking.\n }\n}\n\nexport function getInitialLandingPath(win: Window = window) {\n try {\n const value = win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}path`)\n return value ? normalizeInitialLandingPath(value) : undefined\n } catch {\n return undefined\n }\n}\n\nexport function getInitialContactAttribution(win: Window = window): ContactAttributionSummary {\n const fallbackPath = normalizeInitialLandingPath(win.location.pathname)\n try {\n const initialLandingPath = normalizeInitialLandingPath(\n win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}path`) ?? fallbackPath,\n )\n const visitSource = win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}source`)\n const visitSourceDetail = win.sessionStorage.getItem(\n `${INITIAL_LANDING_STORAGE_PREFIX}sourceDetail`,\n )\n return normalizeContactAttributionSummary({\n initialLandingPath,\n visitSource: isContactVisitSource(visitSource) ? visitSource : 'direct-or-unknown',\n ...(visitSourceDetail ? { visitSourceDetail } : {}),\n })\n } catch {\n return { initialLandingPath: fallbackPath, visitSource: 'direct-or-unknown' }\n }\n}\n\nexport function getPhoneCallLocation(link: HTMLAnchorElement): PhoneCallLocation {\n const explicitLocation = link.dataset.phoneLocation as PhoneCallLocation | undefined\n\n if (explicitLocation) return explicitLocation\n if (link.closest('header')) return 'header'\n if (link.closest('footer')) return 'footer'\n if (link.closest('[data-phone-location=\"hero\"]')) return 'hero'\n if (link.closest('[data-phone-location=\"cta\"]')) return 'cta'\n\n return 'page'\n}\n\nexport function installPhoneClickListener(options: {\n trackPhoneLinkClick: (context: {\n pagePath: string\n location: PhoneCallLocation\n initialLandingPath?: string\n }) => void\n win?: Window\n dedupeWindowMs?: number\n}) {\n const win = options.win ?? window\n const trackPhoneLinkClick = options.trackPhoneLinkClick\n if (!trackPhoneLinkClick) {\n throw new Error('installPhoneClickListener requires trackPhoneLinkClick.')\n }\n let lastEvent: LastPhoneCallEvent | undefined\n\n const handlePhoneClick = (event: MouseEvent) => {\n const target = event.target\n if (!(target instanceof Element)) return\n\n const link = target.closest<HTMLAnchorElement>('a[href^=\"tel:\"]')\n if (!link) return\n\n const pagePath = win.location.pathname || '/'\n const href = link.getAttribute('href') || link.href\n const key = createPhoneCallDedupeKey(pagePath, href)\n const now = Date.now()\n\n if (\n !shouldTrackPhoneCallClick({\n key,\n now,\n lastEvent,\n dedupeWindowMs: options.dedupeWindowMs,\n })\n ) {\n return\n }\n\n lastEvent = { key, at: now }\n trackPhoneLinkClick({\n pagePath,\n location: getPhoneCallLocation(link),\n initialLandingPath: getInitialLandingPath(win),\n })\n }\n\n win.document.addEventListener('click', handlePhoneClick, true)\n\n return () => {\n win.document.removeEventListener('click', handlePhoneClick, true)\n }\n}\n"]}
1
+ {"version":3,"file":"instrumentation-client.js","sourceRoot":"","sources":["../src/instrumentation-client.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n isContactVisitSource,\n type ContactAttributionSummary,\n} from '@brandlab-digital/site-core/contact'\nimport {\n PHONE_CALL_PLACEMENTS,\n type BrandlabAnalytics,\n type PhoneCallPlacement,\n} from './analytics.js'\nimport {\n classifyContactAttribution,\n normalizeContactAttributionSummary,\n} from './contact/attribution.js'\n\nexport type PhoneCallLocation = PhoneCallPlacement\n\nexport interface LastPhoneCallEvent {\n key: string\n at: number\n}\n\nexport const INITIAL_LANDING_STORAGE_PREFIX = 'brandlab.initialLanding.'\nexport const DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS = 1200\n\nexport function normalizeInitialLandingPath(pathname: string | null | undefined) {\n const trimmed = pathname?.trim() || '/'\n if (\n !trimmed.startsWith('/') ||\n trimmed.startsWith('//') ||\n trimmed.includes('\\\\') ||\n /%(?:2f|5c)/i.test(trimmed) ||\n /[\\u0000-\\u001f\\u007f]/.test(trimmed) ||\n trimmed.includes('?') ||\n trimmed.includes('#')\n ) {\n return '/'\n }\n const normalized = trimmed.replace(/\\/{2,}/g, '/')\n return normalized === '/' ? '/' : normalized.replace(/\\/+$/, '')\n}\n\nexport function createPhoneCallDedupeKey(pagePath: string, href: string) {\n return `${pagePath}|${href}`\n}\n\nexport function shouldTrackPhoneCallClick(options: {\n key: string\n now: number\n lastEvent?: LastPhoneCallEvent\n dedupeWindowMs?: number\n}) {\n const windowMs = options.dedupeWindowMs ?? DEFAULT_PHONE_CALL_DEDUPE_WINDOW_MS\n return !(\n options.lastEvent &&\n options.lastEvent.key === options.key &&\n options.now - options.lastEvent.at < windowMs\n )\n}\n\nexport function persistInitialLanding(win: Window = window) {\n try {\n const pathKey = `${INITIAL_LANDING_STORAGE_PREFIX}path`\n const sourceKey = `${INITIAL_LANDING_STORAGE_PREFIX}source`\n const detailKey = `${INITIAL_LANDING_STORAGE_PREFIX}sourceDetail`\n const existingPath = win.sessionStorage.getItem(pathKey)\n const safePath = normalizeInitialLandingPath(existingPath ?? win.location.pathname)\n if (existingPath !== safePath) {\n win.sessionStorage.setItem(pathKey, safePath)\n }\n const existingSource = win.sessionStorage.getItem(sourceKey)\n if (!isContactVisitSource(existingSource)) {\n const attribution = classifyContactAttribution({\n initialLandingPath: safePath,\n search: win.location.search,\n referrer: win.document.referrer,\n siteOrigin: win.location.origin,\n })\n win.sessionStorage.setItem(sourceKey, attribution.visitSource)\n if (attribution.visitSourceDetail) {\n win.sessionStorage.setItem(detailKey, attribution.visitSourceDetail)\n } else {\n win.sessionStorage.removeItem(detailKey)\n }\n }\n // Remove values written by the pre-RC privacy contract. Query strings and\n // referrers are not needed for canonical lead measurement.\n win.sessionStorage.removeItem(`${INITIAL_LANDING_STORAGE_PREFIX}url`)\n win.sessionStorage.removeItem(`${INITIAL_LANDING_STORAGE_PREFIX}referrer`)\n } catch {\n // Storage failures must not affect navigation or lead tracking.\n }\n}\n\nexport function getInitialLandingPath(win: Window = window) {\n try {\n const value = win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}path`)\n return value ? normalizeInitialLandingPath(value) : undefined\n } catch {\n return undefined\n }\n}\n\nexport function getInitialContactAttribution(win: Window = window): ContactAttributionSummary {\n const fallbackPath = normalizeInitialLandingPath(win.location.pathname)\n try {\n const initialLandingPath = normalizeInitialLandingPath(\n win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}path`) ?? fallbackPath,\n )\n const visitSource = win.sessionStorage.getItem(`${INITIAL_LANDING_STORAGE_PREFIX}source`)\n const visitSourceDetail = win.sessionStorage.getItem(\n `${INITIAL_LANDING_STORAGE_PREFIX}sourceDetail`,\n )\n return normalizeContactAttributionSummary({\n initialLandingPath,\n visitSource: isContactVisitSource(visitSource) ? visitSource : 'direct-or-unknown',\n ...(visitSourceDetail ? { visitSourceDetail } : {}),\n })\n } catch {\n return { initialLandingPath: fallbackPath, visitSource: 'direct-or-unknown' }\n }\n}\n\nfunction placementValue(value: string | null | undefined): PhoneCallLocation | undefined {\n const normalized = value?.trim().toLowerCase()\n return PHONE_CALL_PLACEMENTS.includes(normalized as PhoneCallLocation)\n ? normalized as PhoneCallLocation\n : undefined\n}\n\n/**\n * Placement of a telephone link: its own `data-phone-location`, then a\n * `<header>` or `<footer>` ancestor, then the nearest ancestor declaring a\n * valid `data-phone-location` (for example a hero or call-to-action section),\n * otherwise `page`. Unrecognised values are ignored, so every tap still\n * produces exactly one event.\n */\nexport function getPhoneCallLocation(link: HTMLAnchorElement): PhoneCallLocation {\n const explicit = placementValue(link.getAttribute('data-phone-location'))\n if (explicit) return explicit\n if (link.closest('header')) return 'header'\n if (link.closest('footer')) return 'footer'\n for (\n let ancestor = link.parentElement?.closest('[data-phone-location]');\n ancestor;\n ancestor = ancestor.parentElement?.closest('[data-phone-location]')\n ) {\n const placement = placementValue(ancestor.getAttribute('data-phone-location'))\n if (placement) return placement\n }\n return 'page'\n}\n\nexport function installPhoneClickListener(options: {\n trackPhoneLinkClick: (context: {\n pagePath: string\n location: PhoneCallLocation\n initialLandingPath?: string\n }) => void\n win?: Window\n dedupeWindowMs?: number\n}) {\n const win = options.win ?? window\n const trackPhoneLinkClick = options.trackPhoneLinkClick\n if (!trackPhoneLinkClick) {\n throw new Error('installPhoneClickListener requires trackPhoneLinkClick.')\n }\n let lastEvent: LastPhoneCallEvent | undefined\n\n const handlePhoneClick = (event: MouseEvent) => {\n const target = event.target\n if (!(target instanceof Element)) return\n\n const link = target.closest<HTMLAnchorElement>('a[href^=\"tel:\"]')\n if (!link) return\n\n const pagePath = win.location.pathname || '/'\n const href = link.getAttribute('href') || link.href\n const key = createPhoneCallDedupeKey(pagePath, href)\n const now = Date.now()\n\n if (\n !shouldTrackPhoneCallClick({\n key,\n now,\n lastEvent,\n dedupeWindowMs: options.dedupeWindowMs,\n })\n ) {\n return\n }\n\n lastEvent = { key, at: now }\n trackPhoneLinkClick({\n pagePath,\n location: getPhoneCallLocation(link),\n initialLandingPath: getInitialLandingPath(win),\n })\n }\n\n win.document.addEventListener('click', handlePhoneClick, true)\n\n return () => {\n win.document.removeEventListener('click', handlePhoneClick, true)\n }\n}\n\nconst installedInstrumentation = new WeakMap<Window, () => void>()\n\n/**\n * The whole of `src/instrumentation-client.ts`:\n * `installBrandlabInstrumentation(analytics)`. Stores the privacy-safe landing\n * path and visit source for contact attribution, and installs the single\n * telephone-link listener that sends one `phone_link_click` per tap (rapid\n * repeats of the same link are de-duplicated). Installing twice in one window\n * is a no-op; the returned function removes the listener.\n */\nexport function installBrandlabInstrumentation(\n analytics: Pick<BrandlabAnalytics, 'trackPhoneLinkClick'>,\n options: { win?: Window; dedupeWindowMs?: number } = {},\n): () => void {\n if (!analytics || typeof analytics.trackPhoneLinkClick !== 'function') {\n throw new TypeError('installBrandlabInstrumentation requires the defineBrandlabAnalytics binding.')\n }\n const win = options.win ?? (typeof window === 'undefined' ? undefined : window)\n if (!win) return () => undefined\n const existing = installedInstrumentation.get(win)\n if (existing) return existing\n\n persistInitialLanding(win)\n const removeListener = installPhoneClickListener({\n win,\n ...(options.dedupeWindowMs !== undefined ? { dedupeWindowMs: options.dedupeWindowMs } : {}),\n trackPhoneLinkClick: ({ pagePath, location }) => {\n try {\n analytics.trackPhoneLinkClick({ pagePath, placement: location })\n } catch {\n // Tracking must never block the telephone action.\n }\n },\n })\n const teardown = () => {\n removeListener()\n if (installedInstrumentation.get(win) === teardown) installedInstrumentation.delete(win)\n }\n installedInstrumentation.set(win, teardown)\n return teardown\n}\n"]}
@@ -0,0 +1,34 @@
1
+ import type { GtagTransport } from '@brandlab-digital/site-core/analytics';
2
+ export interface LeadTrackingConfig {
3
+ enabled: boolean;
4
+ enhancedConversionsEnabled?: boolean;
5
+ measurementId?: string | null;
6
+ googleAdsConversionId?: string | null;
7
+ googleAdsConversionLabel?: string | null;
8
+ gtag?: GtagTransport;
9
+ }
10
+ export interface LeadPageParams {
11
+ page_template?: string;
12
+ service_slug?: string;
13
+ location_slug?: string;
14
+ }
15
+ export interface LeadAdsParams {
16
+ currency?: string;
17
+ value?: number;
18
+ }
19
+ export interface LeadContext {
20
+ readonly pagePath: string;
21
+ readonly pageParams: LeadPageParams;
22
+ readonly formData: FormData;
23
+ }
24
+ export interface LeadBinding {
25
+ readonly config: LeadTrackingConfig;
26
+ pageParams(pagePath: string): LeadPageParams;
27
+ adsParams(lead: LeadContext): LeadAdsParams;
28
+ }
29
+ export declare function getBrowserGtag(win?: Window | undefined): GtagTransport | undefined;
30
+ export declare function safePagePath(value: string): string;
31
+ export declare function safeSlug(value: string | undefined): string | undefined;
32
+ export declare function registerLeadBinding(binding: object, lead: LeadBinding): void;
33
+ export declare function leadBindingFor(binding: unknown): LeadBinding | undefined;
34
+ //# sourceMappingURL=lead-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lead-registry.d.ts","sourceRoot":"","sources":["../src/lead-registry.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uCAAuC,CAAA;AAG1E,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,0BAA0B,CAAC,EAAE,OAAO,CAAA;IACpC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,wBAAwB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxC,IAAI,CAAC,EAAE,aAAa,CAAA;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAA;IACnC,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;CAC5B;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAA;IACnC,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,CAAA;IAC5C,SAAS,CAAC,IAAI,EAAE,WAAW,GAAG,aAAa,CAAA;CAC5C;AAED,wBAAgB,cAAc,CAC5B,GAAG,GAAE,MAAM,GAAG,SAA8D,GAC3E,aAAa,GAAG,SAAS,CAG3B;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,UAQzC;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,sBAKjD;AAID,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,QAErE;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,WAAW,GAAG,SAAS,CAExE"}
@@ -0,0 +1,30 @@
1
+ import { normalizeBrandlabRoutePath } from '@brandlab-digital/site-core/routes';
2
+ export function getBrowserGtag(win = typeof window === 'undefined' ? undefined : window) {
3
+ const candidate = win?.gtag;
4
+ return typeof candidate === 'function' ? candidate : undefined;
5
+ }
6
+ export function safePagePath(value) {
7
+ const path = value.trim().split(/[?#]/, 1)[0];
8
+ if (!path || path.length > 500)
9
+ return '/';
10
+ try {
11
+ return normalizeBrandlabRoutePath(path);
12
+ }
13
+ catch {
14
+ return '/';
15
+ }
16
+ }
17
+ export function safeSlug(value) {
18
+ const normalized = value?.trim().toLowerCase();
19
+ return normalized && /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(normalized) && normalized.length <= 80
20
+ ? normalized
21
+ : undefined;
22
+ }
23
+ const bindings = new WeakMap();
24
+ export function registerLeadBinding(binding, lead) {
25
+ bindings.set(binding, Object.freeze(lead));
26
+ }
27
+ export function leadBindingFor(binding) {
28
+ return binding && typeof binding === 'object' ? bindings.get(binding) : undefined;
29
+ }
30
+ //# sourceMappingURL=lead-registry.js.map