@bygghammar/cms-platform 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.0
4
+
5
+ - Added `tracking/injections` — the pure "Code & tracking" helpers (profile
6
+ merging, scope selection, GTM/GA4/Meta/TikTok snippet builders).
7
+ - Added `server/injections-core` — staff/admin CRUD for `site_injections` with a
8
+ host-supplied audit writer, plus cached request-time bundle resolution,
9
+ `isInjectablePath` and `applyInjections`.
10
+ - Added `server/domains-core` — `locale_domains` reads, request-locale and
11
+ admin-domain resolution (with self-host `SITE_URL` precedence) and staff-only
12
+ upsert/delete.
13
+
14
+ ## 0.34.1
15
+
16
+ - `saveContentSectionCore` accepts an optional `data` payload, matching hosts
17
+ whose validator makes the record body optional.
18
+
3
19
  ## 0.34.0
4
20
 
5
21
  - Added `server/content-sections-core` — public reads and staff-only upserts of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -145,7 +145,10 @@
145
145
  "./server/pages-core": "./src/server/pages-core.ts",
146
146
  "./server/resolve-globals": "./src/server/resolve-globals.ts",
147
147
  "./server/content-sections-core": "./src/server/content-sections-core.ts",
148
- "./server/translate-db-core": "./src/server/translate-db-core.ts"
148
+ "./server/translate-db-core": "./src/server/translate-db-core.ts",
149
+ "./tracking/injections": "./src/tracking/injections.ts",
150
+ "./server/injections-core": "./src/server/injections-core.ts",
151
+ "./server/domains-core": "./src/server/domains-core.ts"
149
152
  },
150
153
  "dependencies": {
151
154
  "fflate": "^0.8.3"
@@ -49,7 +49,7 @@ export async function isStaffCore(context: PlatformAuthContext): Promise<boolean
49
49
  */
50
50
  export async function saveContentSectionCore(
51
51
  context: PlatformAuthContext & { supabase: ContentClient },
52
- input: { key: string; data: unknown },
52
+ input: { key: string; data?: unknown },
53
53
  ): Promise<{ ok: true; key: string }> {
54
54
  await assertStaff(context);
55
55
 
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Host-agnostic logic for locale → domain mapping (`locale_domains`).
3
+ *
4
+ * Reads use a privileged client because the table reveals which hostname serves
5
+ * the backoffice; writes run as the signed-in staff user. The host owns the
6
+ * server functions, its locale union and the request headers.
7
+ */
8
+
9
+ import { assertStaff, type PlatformAuthContext } from "./guards";
10
+
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
12
+ /** Structural subset of a Supabase-shaped client used by this core. */
13
+ export type DomainsClient = { from: (table: string) => any };
14
+ /* eslint-enable @typescript-eslint/no-explicit-any */
15
+
16
+ export type LocaleDomainRecord = {
17
+ locale: string;
18
+ domain: string;
19
+ isDefault: boolean;
20
+ isAdminDomain: boolean;
21
+ };
22
+
23
+ /** Strip protocol, trailing slashes and casing from a stored domain. */
24
+ export function normalizeDomain(domain: string): string {
25
+ return domain
26
+ .replace(/^https?:\/\//, "")
27
+ .replace(/\/+$/, "")
28
+ .toLowerCase();
29
+ }
30
+
31
+ /** Strip a leading `www.` and casing from a request host. */
32
+ export function normalizeHost(host: string): string {
33
+ return host.replace(/^www\./, "").toLowerCase();
34
+ }
35
+
36
+ function rowToDomain(row: {
37
+ locale: string;
38
+ domain: string;
39
+ is_default: boolean;
40
+ is_admin_domain: boolean;
41
+ }): LocaleDomainRecord {
42
+ return {
43
+ locale: row.locale,
44
+ domain: row.domain,
45
+ isDefault: row.is_default,
46
+ isAdminDomain: row.is_admin_domain,
47
+ };
48
+ }
49
+
50
+ /** Every configured mapping, ordered by locale. Requires a privileged client. */
51
+ export async function listLocaleDomainsCore(client: DomainsClient): Promise<LocaleDomainRecord[]> {
52
+ const { data, error } = await client
53
+ .from("locale_domains")
54
+ .select("locale, domain, is_default, is_admin_domain")
55
+ .order("locale");
56
+ if (error) throw new Error(error.message);
57
+ return (data ?? []).map(rowToDomain);
58
+ }
59
+
60
+ /** The mapping whose domain matches `host`, ignoring protocol and `www.`. */
61
+ export function matchDomain(
62
+ domains: LocaleDomainRecord[],
63
+ host: string,
64
+ ): LocaleDomainRecord | undefined {
65
+ const normalizedHost = normalizeHost(host);
66
+ return domains.find((d) => {
67
+ const normalized = normalizeDomain(d.domain);
68
+ return normalizedHost === normalized || normalizedHost === `www.${normalized}`;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Resolve the active locale for a request host, falling back to the default
74
+ * mapping, then to the first mapping, then to a synthetic host-based record.
75
+ */
76
+ export function resolveRequestLocale(
77
+ domains: LocaleDomainRecord[],
78
+ host: string,
79
+ defaultLocale: string,
80
+ ): { locale: string; domain: LocaleDomainRecord } {
81
+ const match = matchDomain(domains, host);
82
+ if (match) return { locale: match.locale, domain: match };
83
+
84
+ const fallback = domains.find((d) => d.isDefault) ??
85
+ domains[0] ?? {
86
+ locale: defaultLocale,
87
+ domain: host || "localhost",
88
+ isDefault: true,
89
+ isAdminDomain: true,
90
+ };
91
+ return { locale: fallback.locale, domain: fallback };
92
+ }
93
+
94
+ /**
95
+ * The domain that serves the backoffice.
96
+ *
97
+ * `forcedHost` (a self-host `SITE_URL`) is authoritative: an imported row from
98
+ * another deployment must never send the backoffice to that other hostname.
99
+ * Otherwise the flagged database row wins, and the request host is last resort.
100
+ */
101
+ export function resolveAdminDomain(
102
+ domains: LocaleDomainRecord[],
103
+ options: { forcedHost?: string | null; requestHost?: string | null; defaultLocale: string },
104
+ ): LocaleDomainRecord {
105
+ const dbAdmin = domains.find((d) => d.isAdminDomain) ?? domains[0];
106
+
107
+ if (options.forcedHost) {
108
+ return {
109
+ locale: dbAdmin?.locale ?? options.defaultLocale,
110
+ domain: options.forcedHost,
111
+ isDefault: dbAdmin?.isDefault ?? true,
112
+ isAdminDomain: true,
113
+ };
114
+ }
115
+ if (dbAdmin) return dbAdmin;
116
+ return {
117
+ locale: options.defaultLocale,
118
+ domain: options.requestHost || "localhost",
119
+ isDefault: true,
120
+ isAdminDomain: true,
121
+ };
122
+ }
123
+
124
+ /** Absolute origin for a locale, or "" when the locale has no mapping. */
125
+ export function resolveLocaleOrigin(locale: string, domains: LocaleDomainRecord[]): string {
126
+ const match = domains.find((d) => d.locale === locale);
127
+ if (!match) return "";
128
+ return `https://${normalizeDomain(match.domain)}`;
129
+ }
130
+
131
+ /**
132
+ * Staff-only upsert of one mapping. Keeps at most one default and one admin
133
+ * domain by clearing the flags on every other locale first.
134
+ */
135
+ export async function saveLocaleDomainCore(
136
+ context: PlatformAuthContext & { supabase: DomainsClient },
137
+ input: { locale: string; domain: string; isDefault: boolean; isAdminDomain: boolean },
138
+ options: { invalidDomainMessage?: string } = {},
139
+ ): Promise<{ ok: true }> {
140
+ await assertStaff(context);
141
+
142
+ const normalized = normalizeDomain(input.domain);
143
+ if (!normalized) throw new Error(options.invalidDomainMessage ?? "Invalid domain");
144
+
145
+ if (input.isDefault) {
146
+ await context.supabase
147
+ .from("locale_domains")
148
+ .update({ is_default: false })
149
+ .neq("locale", input.locale);
150
+ }
151
+ if (input.isAdminDomain) {
152
+ await context.supabase
153
+ .from("locale_domains")
154
+ .update({ is_admin_domain: false })
155
+ .neq("locale", input.locale);
156
+ }
157
+
158
+ const { error } = await context.supabase.from("locale_domains").upsert(
159
+ {
160
+ locale: input.locale,
161
+ domain: normalized,
162
+ is_default: input.isDefault,
163
+ is_admin_domain: input.isAdminDomain,
164
+ },
165
+ { onConflict: "locale" },
166
+ );
167
+ if (error) throw new Error(error.message);
168
+ return { ok: true };
169
+ }
170
+
171
+ /** Staff-only delete of one mapping. */
172
+ export async function deleteLocaleDomainCore(
173
+ context: PlatformAuthContext & { supabase: DomainsClient },
174
+ locale: string,
175
+ ): Promise<{ ok: true }> {
176
+ await assertStaff(context);
177
+ const { error } = await context.supabase.from("locale_domains").delete().eq("locale", locale);
178
+ if (error) throw new Error(error.message);
179
+ return { ok: true };
180
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Host-agnostic logic for the "Code & tracking" admin area (`site_injections`).
3
+ *
4
+ * The host owns the server-function wrappers and the clients; this module owns
5
+ * the row mapping, validation-independent CRUD, the audit trail action names and
6
+ * the request-time resolution of the merged injection bundle, so every site
7
+ * behaves identically.
8
+ */
9
+
10
+ import {
11
+ EMPTY_BUNDLE,
12
+ mergeBundles,
13
+ normalizeHostname,
14
+ selectProfiles,
15
+ type InjectionBundle,
16
+ type InjectionProfile,
17
+ } from "../tracking/injections";
18
+ import { actorEmail, assertRole, type PlatformAuthContext } from "./guards";
19
+
20
+ /* eslint-disable @typescript-eslint/no-explicit-any */
21
+ /** Structural subset of a Supabase-shaped client used by this core. */
22
+ export type InjectionsClient = { from: (table: string) => any };
23
+ /* eslint-enable @typescript-eslint/no-explicit-any */
24
+
25
+ export const INJECTION_SELECT_COLS =
26
+ "id, name, scope_type, scope_value, enabled, head_html, body_start_html, body_end_html, gtm_id, ga4_id, meta_pixel_id, tiktok_pixel_id, extra_meta, sort_order, updated_at";
27
+
28
+ /** Normalize a database row into the profile shape the UI works with. */
29
+ export function rowToInjectionProfile(row: Record<string, unknown>): InjectionProfile {
30
+ return {
31
+ id: String(row.id),
32
+ name: String(row.name ?? ""),
33
+ scope_type: (row.scope_type as InjectionProfile["scope_type"]) ?? "global",
34
+ scope_value: String(row.scope_value ?? ""),
35
+ enabled: Boolean(row.enabled),
36
+ head_html: String(row.head_html ?? ""),
37
+ body_start_html: String(row.body_start_html ?? ""),
38
+ body_end_html: String(row.body_end_html ?? ""),
39
+ gtm_id: String(row.gtm_id ?? ""),
40
+ ga4_id: String(row.ga4_id ?? ""),
41
+ meta_pixel_id: String(row.meta_pixel_id ?? ""),
42
+ tiktok_pixel_id: String(row.tiktok_pixel_id ?? ""),
43
+ extra_meta: Array.isArray(row.extra_meta)
44
+ ? (row.extra_meta as InjectionProfile["extra_meta"])
45
+ : [],
46
+ sort_order: Number(row.sort_order ?? 100),
47
+ updated_at: row.updated_at ? String(row.updated_at) : undefined,
48
+ };
49
+ }
50
+
51
+ /** Signed-in staff read of every profile, in scope then sort order. */
52
+ export async function listInjectionsCore(client: InjectionsClient): Promise<InjectionProfile[]> {
53
+ const { data, error } = await client
54
+ .from("site_injections")
55
+ .select(INJECTION_SELECT_COLS)
56
+ .order("scope_type")
57
+ .order("sort_order");
58
+ if (error) throw new Error(error.message);
59
+ return ((data ?? []) as Record<string, unknown>[]).map(rowToInjectionProfile);
60
+ }
61
+
62
+ export type InjectionInput = Omit<InjectionProfile, "id" | "updated_at"> & { id?: string };
63
+
64
+ /** Host-provided audit-log writer; keeps privileged clients out of the core. */
65
+ export type AuditWriter = (entry: {
66
+ actorId: string;
67
+ actorEmail: string | null;
68
+ action: string;
69
+ entity: string;
70
+ entityId: string | null;
71
+ meta: Record<string, unknown>;
72
+ }) => Promise<void>;
73
+
74
+ function toRow(input: InjectionInput) {
75
+ return {
76
+ name: input.name,
77
+ scope_type: input.scope_type,
78
+ scope_value: input.scope_type === "global" ? "" : input.scope_value,
79
+ enabled: input.enabled,
80
+ head_html: input.head_html,
81
+ body_start_html: input.body_start_html,
82
+ body_end_html: input.body_end_html,
83
+ gtm_id: input.gtm_id,
84
+ ga4_id: input.ga4_id,
85
+ meta_pixel_id: input.meta_pixel_id,
86
+ tiktok_pixel_id: input.tiktok_pixel_id,
87
+ extra_meta: input.extra_meta,
88
+ sort_order: input.sort_order,
89
+ };
90
+ }
91
+
92
+ /** Admin-only create/update of one profile, with an audit entry. */
93
+ export async function saveInjectionCore(
94
+ context: PlatformAuthContext & { supabase: InjectionsClient },
95
+ input: InjectionInput,
96
+ audit: AuditWriter,
97
+ ): Promise<{ id: string }> {
98
+ await assertRole(context, "admin");
99
+ const row = toRow(input);
100
+ const scope = `${row.scope_type}:${row.scope_value}`;
101
+
102
+ if (input.id) {
103
+ const { error } = await context.supabase
104
+ .from("site_injections")
105
+ .update(row)
106
+ .eq("id", input.id);
107
+ if (error) throw new Error(error.message);
108
+ await audit({
109
+ actorId: context.userId,
110
+ actorEmail: actorEmail(context),
111
+ action: "injection.update",
112
+ entity: "site_injections",
113
+ entityId: input.id,
114
+ meta: { name: input.name, scope, enabled: input.enabled },
115
+ });
116
+ return { id: input.id };
117
+ }
118
+
119
+ const { data: inserted, error } = await context.supabase
120
+ .from("site_injections")
121
+ .insert(row)
122
+ .select("id")
123
+ .single();
124
+ if (error) throw new Error(error.message);
125
+ const id = String(inserted?.id ?? "");
126
+ await audit({
127
+ actorId: context.userId,
128
+ actorEmail: actorEmail(context),
129
+ action: "injection.create",
130
+ entity: "site_injections",
131
+ entityId: id || null,
132
+ meta: { name: input.name, scope },
133
+ });
134
+ return { id };
135
+ }
136
+
137
+ /** Admin-only delete of one profile, with an audit entry. */
138
+ export async function deleteInjectionCore(
139
+ context: PlatformAuthContext & { supabase: InjectionsClient },
140
+ id: string,
141
+ audit: AuditWriter,
142
+ ): Promise<{ ok: true }> {
143
+ await assertRole(context, "admin");
144
+ const { error } = await context.supabase.from("site_injections").delete().eq("id", id);
145
+ if (error) throw new Error(error.message);
146
+ await audit({
147
+ actorId: context.userId,
148
+ actorEmail: actorEmail(context),
149
+ action: "injection.delete",
150
+ entity: "site_injections",
151
+ entityId: id,
152
+ meta: {},
153
+ });
154
+ return { ok: true };
155
+ }
156
+
157
+ /* ---------------- Request-time resolution ---------------- */
158
+
159
+ const CACHE_TTL_MS = 30_000;
160
+ const cache = new Map<string, { at: number; bundle: InjectionBundle }>();
161
+
162
+ /**
163
+ * Resolve the merged bundle for a request host. Never throws: a backend hiccup
164
+ * degrades to "no injection" so the site still renders.
165
+ *
166
+ * `client` must be a privileged (service-role) client — `site_injections` and
167
+ * `locale_domains` are not readable anonymously.
168
+ */
169
+ export async function resolveInjectionsCore(
170
+ client: InjectionsClient,
171
+ host: string,
172
+ options: { defaultLocale?: string } = {},
173
+ ): Promise<InjectionBundle> {
174
+ const key = normalizeHostname(host);
175
+ const hit = cache.get(key);
176
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.bundle;
177
+
178
+ let bundle = EMPTY_BUNDLE;
179
+ try {
180
+ const [{ data: rows }, { data: domains }] = await Promise.all([
181
+ client.from("site_injections").select("*").eq("enabled", true),
182
+ client.from("locale_domains").select("locale, domain, is_default"),
183
+ ]);
184
+
185
+ if (rows && rows.length > 0) {
186
+ const list = (domains ?? []) as { locale: string; domain: string; is_default: boolean }[];
187
+ const match = list.find((d) => normalizeHostname(d.domain) === key);
188
+ const fallback = list.find((d) => d.is_default);
189
+ const locale = match?.locale ?? fallback?.locale ?? options.defaultLocale ?? "en";
190
+ const profiles = (rows as Record<string, unknown>[]).map(rowToInjectionProfile);
191
+ bundle = mergeBundles(selectProfiles(profiles, locale, host));
192
+ }
193
+ } catch {
194
+ bundle = EMPTY_BUNDLE;
195
+ }
196
+
197
+ cache.set(key, { at: Date.now(), bundle });
198
+ return bundle;
199
+ }
200
+
201
+ /** True for paths that must never receive tracking code. */
202
+ export function isInjectablePath(pathname: string, backofficePath = "/kontoret"): boolean {
203
+ if (pathname.startsWith(backofficePath)) return false;
204
+ if (pathname.startsWith("/preview")) return false;
205
+ if (pathname.startsWith("/api")) return false;
206
+ if (pathname.startsWith("/_serverFn")) return false;
207
+ return true;
208
+ }
209
+
210
+ /** Insert the bundle's three chunks into a rendered HTML document. */
211
+ export function applyInjections(html: string, bundle: InjectionBundle): string {
212
+ let out = html;
213
+ if (bundle.head && out.includes("</head>")) {
214
+ out = out.replace("</head>", `${bundle.head}</head>`);
215
+ }
216
+ if (bundle.bodyStart) {
217
+ out = out.replace(/<body([^>]*)>/i, (m) => `${m}${bundle.bodyStart}`);
218
+ }
219
+ if (bundle.bodyEnd && out.includes("</body>")) {
220
+ out = out.replace("</body>", `${bundle.bodyEnd}</body>`);
221
+ }
222
+ return out;
223
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Pure helpers for the "Code & tracking" admin area.
3
+ *
4
+ * A profile holds raw code for three injection points plus well-known
5
+ * tracking IDs. Profiles are merged in scope order (global → locale → domain)
6
+ * into three final HTML strings that the root shell renders.
7
+ */
8
+
9
+ export type InjectionScopeType = "global" | "locale" | "domain";
10
+
11
+ export type ExtraMeta = { name: string; content: string };
12
+
13
+ export type InjectionProfile = {
14
+ id: string;
15
+ name: string;
16
+ scope_type: InjectionScopeType;
17
+ scope_value: string;
18
+ enabled: boolean;
19
+ head_html: string;
20
+ body_start_html: string;
21
+ body_end_html: string;
22
+ gtm_id: string;
23
+ ga4_id: string;
24
+ meta_pixel_id: string;
25
+ tiktok_pixel_id: string;
26
+ extra_meta: ExtraMeta[];
27
+ sort_order: number;
28
+ updated_at?: string;
29
+ };
30
+
31
+ export type InjectionBundle = {
32
+ head: string;
33
+ bodyStart: string;
34
+ bodyEnd: string;
35
+ };
36
+
37
+ export const EMPTY_BUNDLE: InjectionBundle = { head: "", bodyStart: "", bodyEnd: "" };
38
+
39
+ /** Escapes a value used inside a generated snippet / attribute. */
40
+ function esc(v: string): string {
41
+ return v.replace(/[\\'"<>]/g, "");
42
+ }
43
+
44
+ export function normalizeHostname(host: string): string {
45
+ return host
46
+ .replace(/^https?:\/\//, "")
47
+ .replace(/\/+$/, "")
48
+ .replace(/:\d+$/, "")
49
+ .replace(/^www\./, "")
50
+ .toLowerCase();
51
+ }
52
+
53
+ /* ---------------- Snippet builders ---------------- */
54
+
55
+ export function gtmHead(id: string): string {
56
+ const gtm = esc(id);
57
+ return `<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtm}');</script>`;
58
+ }
59
+
60
+ export function gtmBody(id: string): string {
61
+ const gtm = esc(id);
62
+ return `<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${gtm}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>`;
63
+ }
64
+
65
+ export function ga4Head(id: string): string {
66
+ const g = esc(id);
67
+ return `<script async src="https://www.googletagmanager.com/gtag/js?id=${g}"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${g}');</script>`;
68
+ }
69
+
70
+ export function metaPixelHead(id: string): string {
71
+ const p = esc(id);
72
+ return `<script>!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');fbq('init','${p}');fbq('track','PageView');</script>`;
73
+ }
74
+
75
+ export function metaPixelBody(id: string): string {
76
+ const p = esc(id);
77
+ return `<noscript><img height="1" width="1" style="display:none" alt="" src="https://www.facebook.com/tr?id=${p}&ev=PageView&noscript=1" /></noscript>`;
78
+ }
79
+
80
+ export function tiktokPixelHead(id: string): string {
81
+ const p = esc(id);
82
+ return `<script>!function(w,d,t){w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"];ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e};ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{};ttq._i[e]=[];ttq._i[e]._u=r;ttq._t=ttq._t||{};ttq._t[e]=+new Date;ttq._o=ttq._o||{};ttq._o[e]=n||{};var o=d.createElement("script");o.type="text/javascript";o.async=!0;o.src=r+"?sdkid="+e+"&lib="+t;var a=d.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};ttq.load('${p}');ttq.page();}(window,document,'ttq');</script>`;
83
+ }
84
+
85
+ export function extraMetaTags(items: ExtraMeta[]): string {
86
+ return items
87
+ .filter((m) => m.name && m.content)
88
+ .map((m) => `<meta name="${esc(m.name)}" content="${m.content.replace(/"/g, "&quot;")}" />`)
89
+ .join("");
90
+ }
91
+
92
+ /* ---------------- Merge ---------------- */
93
+
94
+ /** Turns one profile into its three HTML chunks (IDs first, then raw code). */
95
+ export function profileToBundle(p: InjectionProfile): InjectionBundle {
96
+ const head: string[] = [];
97
+ const bodyStart: string[] = [];
98
+ const bodyEnd: string[] = [];
99
+
100
+ if (p.gtm_id?.trim()) {
101
+ head.push(gtmHead(p.gtm_id.trim()));
102
+ bodyStart.push(gtmBody(p.gtm_id.trim()));
103
+ }
104
+ if (p.ga4_id?.trim()) head.push(ga4Head(p.ga4_id.trim()));
105
+ if (p.meta_pixel_id?.trim()) {
106
+ head.push(metaPixelHead(p.meta_pixel_id.trim()));
107
+ bodyStart.push(metaPixelBody(p.meta_pixel_id.trim()));
108
+ }
109
+ if (p.tiktok_pixel_id?.trim()) head.push(tiktokPixelHead(p.tiktok_pixel_id.trim()));
110
+
111
+ const extras = Array.isArray(p.extra_meta) ? p.extra_meta : [];
112
+ const metas = extraMetaTags(extras);
113
+ if (metas) head.push(metas);
114
+
115
+ if (p.head_html?.trim()) head.push(p.head_html);
116
+ if (p.body_start_html?.trim()) bodyStart.push(p.body_start_html);
117
+ if (p.body_end_html?.trim()) bodyEnd.push(p.body_end_html);
118
+
119
+ return {
120
+ head: head.join("\n"),
121
+ bodyStart: bodyStart.join("\n"),
122
+ bodyEnd: bodyEnd.join("\n"),
123
+ };
124
+ }
125
+
126
+ /** Profiles that apply to the given locale + host, in scope order. */
127
+ export function selectProfiles(
128
+ profiles: InjectionProfile[],
129
+ locale: string,
130
+ host: string,
131
+ ): InjectionProfile[] {
132
+ const h = normalizeHostname(host);
133
+ const rank: Record<InjectionScopeType, number> = { global: 0, locale: 1, domain: 2 };
134
+ return profiles
135
+ .filter((p) => p.enabled)
136
+ .filter((p) => {
137
+ if (p.scope_type === "global") return true;
138
+ if (p.scope_type === "locale") return p.scope_value === locale;
139
+ return normalizeHostname(p.scope_value) === h;
140
+ })
141
+ .sort((a, b) => rank[a.scope_type] - rank[b.scope_type] || a.sort_order - b.sort_order);
142
+ }
143
+
144
+ export function mergeBundles(profiles: InjectionProfile[]): InjectionBundle {
145
+ return profiles.map(profileToBundle).reduce<InjectionBundle>(
146
+ (acc, b) => ({
147
+ head: [acc.head, b.head].filter(Boolean).join("\n"),
148
+ bodyStart: [acc.bodyStart, b.bodyStart].filter(Boolean).join("\n"),
149
+ bodyEnd: [acc.bodyEnd, b.bodyEnd].filter(Boolean).join("\n"),
150
+ }),
151
+ EMPTY_BUNDLE,
152
+ );
153
+ }