@bygghammar/cms-platform 0.34.1 → 0.36.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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Minimal SMTP client for the serverless runtime.
3
+ *
4
+ * Node mail libraries (nodemailer & co.) cannot run here, but the runtime does
5
+ * expose raw TCP sockets, so we speak SMTP directly:
6
+ *
7
+ * greeting -> EHLO -> STARTTLS -> TLS upgrade -> EHLO -> AUTH -> MAIL FROM
8
+ * -> RCPT TO -> DATA -> message -> QUIT
9
+ *
10
+ * Only explicit-TLS-on-connect (port 465) is out of reach: the socket API can
11
+ * upgrade a plain connection to TLS but cannot open an encrypted one. Use the
12
+ * submission port (587) instead.
13
+ *
14
+ * Server-only. Never import from client code.
15
+ */
16
+
17
+ export type SmtpConfig = {
18
+ host: string;
19
+ port: number;
20
+ username?: string;
21
+ password?: string;
22
+ };
23
+
24
+ export type SmtpMessage = {
25
+ to: string;
26
+ fromEmail: string;
27
+ fromName?: string | null;
28
+ replyTo?: string | null;
29
+ subject: string;
30
+ text: string;
31
+ html: string;
32
+ };
33
+
34
+ export type SmtpResult = { ok: true } | { ok: false; error: string };
35
+
36
+ const CONNECT_TIMEOUT_MS = 15_000;
37
+ const HOST_RE =
38
+ /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
39
+ const BLOCKED_HOSTS = new Set([
40
+ "localhost",
41
+ "localhost.localdomain",
42
+ "127.0.0.1",
43
+ "0.0.0.0",
44
+ "::1",
45
+ ]);
46
+
47
+ /** Ports we allow outbound. 465 is deliberately excluded (implicit TLS). */
48
+ export const ALLOWED_SMTP_PORTS = [25, 587, 2525] as const;
49
+
50
+ export function validateSmtpTarget(host: string, port: number): string | null {
51
+ const h = host.trim().toLowerCase();
52
+ if (!h) return "SMTP host is missing in the provider config.";
53
+ if (BLOCKED_HOSTS.has(h)) return "Connections to localhost are not allowed.";
54
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
55
+ const [a, b] = h.split(".").map(Number);
56
+ if (
57
+ a === 10 ||
58
+ a === 127 ||
59
+ (a === 172 && b >= 16 && b <= 31) ||
60
+ (a === 192 && b === 168) ||
61
+ (a === 169 && b === 254)
62
+ ) {
63
+ return "Connections to private network addresses are not allowed.";
64
+ }
65
+ } else if (!HOST_RE.test(h)) {
66
+ return `"${host}" is not a valid SMTP host name.`;
67
+ }
68
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return "SMTP port is invalid.";
69
+ if (port === 465) {
70
+ return "Port 465 (implicit TLS) is not supported by this runtime. Use port 587 with STARTTLS instead.";
71
+ }
72
+ if (!(ALLOWED_SMTP_PORTS as readonly number[]).includes(port)) {
73
+ return `Port ${port} is not allowed. Use 587 (recommended), 2525 or 25.`;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /* ---------------- reply parsing ---------------- */
79
+
80
+ export type SmtpReply = { code: number; lines: string[]; text: string };
81
+
82
+ /** True when `buf` holds a complete SMTP reply (last line uses "NNN " not "NNN-"). */
83
+ export function isCompleteReply(buf: string): boolean {
84
+ const lines = buf.split(/\r?\n/).filter((l) => l.length > 0);
85
+ if (!lines.length) return false;
86
+ const last = lines[lines.length - 1];
87
+ return /^\d{3}(\s|$)/.test(last);
88
+ }
89
+
90
+ export function parseReply(buf: string): SmtpReply {
91
+ const lines = buf.split(/\r?\n/).filter((l) => l.length > 0);
92
+ const code = Number(lines[lines.length - 1]?.slice(0, 3)) || 0;
93
+ const text = lines
94
+ .map((l) => l.slice(4))
95
+ .join(" ")
96
+ .trim();
97
+ return { code, lines, text };
98
+ }
99
+
100
+ /* ---------------- MIME message ---------------- */
101
+
102
+ function encodeHeaderWord(value: string): string {
103
+ // RFC 2047 for any non-ASCII header content.
104
+ if (/^[\x20-\x7e]*$/.test(value)) return value;
105
+ const bytes = new TextEncoder().encode(value);
106
+ let binary = "";
107
+ for (const b of bytes) binary += String.fromCharCode(b);
108
+ return `=?UTF-8?B?${btoa(binary)}?=`;
109
+ }
110
+
111
+ function addressHeader(email: string, name?: string | null): string {
112
+ return name ? `${encodeHeaderWord(name)} <${email}>` : email;
113
+ }
114
+
115
+ /** Escapes leading dots so message content cannot terminate the DATA stage. */
116
+ function dotStuff(body: string): string {
117
+ return body.replace(/\r?\n/g, "\r\n").replace(/^\./gm, "..");
118
+ }
119
+
120
+ export function buildMimeMessage(msg: SmtpMessage, domain: string): string {
121
+ const boundary = `sba-${crypto.randomUUID()}`;
122
+ const headers = [
123
+ `From: ${addressHeader(msg.fromEmail, msg.fromName)}`,
124
+ `To: ${msg.to}`,
125
+ ...(msg.replyTo ? [`Reply-To: ${msg.replyTo}`] : []),
126
+ `Subject: ${encodeHeaderWord(msg.subject)}`,
127
+ `Date: ${new Date().toUTCString()}`,
128
+ `Message-ID: <${crypto.randomUUID()}@${domain}>`,
129
+ "MIME-Version: 1.0",
130
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
131
+ ];
132
+ const body = [
133
+ `--${boundary}`,
134
+ 'Content-Type: text/plain; charset="UTF-8"',
135
+ "Content-Transfer-Encoding: 8bit",
136
+ "",
137
+ dotStuff(msg.text),
138
+ `--${boundary}`,
139
+ 'Content-Type: text/html; charset="UTF-8"',
140
+ "Content-Transfer-Encoding: 8bit",
141
+ "",
142
+ dotStuff(msg.html),
143
+ `--${boundary}--`,
144
+ ].join("\r\n");
145
+ return `${headers.join("\r\n")}\r\n\r\n${body}`;
146
+ }
147
+
148
+ /* ---------------- connection ---------------- */
149
+
150
+ type AnySocket = {
151
+ readable: ReadableStream<Uint8Array>;
152
+ writable: WritableStream<Uint8Array>;
153
+ close: () => Promise<void> | void;
154
+ startTls: () => AnySocket;
155
+ };
156
+
157
+ /**
158
+ * Node fallback (vite dev / any Node host): wrap a `node:net` socket in the
159
+ * same web-stream shape the Workers socket API exposes, with `startTls()`
160
+ * upgrading the live connection via `node:tls`.
161
+ */
162
+ type NodeRawSocket = {
163
+ on(event: "data", cb: (chunk: Buffer) => void): void;
164
+ once(event: string, cb: (...args: never[]) => void): void;
165
+ off(event: "data", cb: (chunk: Buffer) => void): void;
166
+ removeAllListeners(event: string): void;
167
+ write(chunk: Buffer, cb: (err?: Error | null) => void): void;
168
+ destroy(): void;
169
+ __smtpHost?: string;
170
+ };
171
+
172
+ type NodeNetModule = { connect: (opts: { host: string; port: number }) => NodeRawSocket };
173
+ type NodeTlsModule = {
174
+ connect: (opts: {
175
+ socket: NodeRawSocket;
176
+ servername?: string;
177
+ rejectUnauthorized: boolean;
178
+ }) => NodeRawSocket;
179
+ };
180
+ type WorkerSocketsModule = {
181
+ connect: (
182
+ address: { hostname: string; port: number },
183
+ options: { allowHalfOpen: boolean; secureTransport: string },
184
+ ) => AnySocket;
185
+ };
186
+
187
+ function wrapNodeSocket(raw: NodeRawSocket): AnySocket {
188
+ let onData: ((chunk: Buffer) => void) | null = null;
189
+ const readable = new ReadableStream<Uint8Array>({
190
+ start(controller) {
191
+ onData = (chunk) => controller.enqueue(new Uint8Array(chunk));
192
+ raw.on("data", onData);
193
+ raw.once("end", () => {
194
+ try {
195
+ controller.close();
196
+ } catch {
197
+ /* closed */
198
+ }
199
+ });
200
+ raw.once("error", (e: Error) => {
201
+ try {
202
+ controller.error(e);
203
+ } catch {
204
+ /* closed */
205
+ }
206
+ });
207
+ },
208
+ cancel() {
209
+ if (onData) raw.off("data", onData);
210
+ },
211
+ });
212
+ const writable = new WritableStream<Uint8Array>({
213
+ write(chunk) {
214
+ return new Promise<void>((resolve, reject) =>
215
+ raw.write(Buffer.from(chunk), (err?: Error | null) => (err ? reject(err) : resolve())),
216
+ );
217
+ },
218
+ });
219
+ return {
220
+ readable,
221
+ writable,
222
+ close: () => {
223
+ raw.destroy();
224
+ },
225
+ startTls: () => {
226
+ if (onData) raw.off("data", onData);
227
+ raw.removeAllListeners("end");
228
+ raw.removeAllListeners("error");
229
+
230
+ const tls = (globalThis as unknown as { __smtpTls: NodeTlsModule }).__smtpTls;
231
+ const secure = tls.connect({
232
+ socket: raw,
233
+ servername: raw.__smtpHost,
234
+ rejectUnauthorized: true,
235
+ });
236
+ secure.__smtpHost = raw.__smtpHost;
237
+ return wrapNodeSocket(secure);
238
+ },
239
+ };
240
+ }
241
+
242
+ async function openNodeSocket(host: string, port: number): Promise<AnySocket | null> {
243
+ try {
244
+ const net = (await import(/* @vite-ignore */ "node:net" as string)) as unknown as NodeNetModule;
245
+ const tls = (await import(/* @vite-ignore */ "node:tls" as string)) as unknown as NodeTlsModule;
246
+ if (!net?.connect) return null;
247
+ (globalThis as unknown as { __smtpTls: NodeTlsModule }).__smtpTls = tls;
248
+ const raw = await new Promise<NodeRawSocket>((resolve, reject) => {
249
+ const sock = net.connect({ host, port });
250
+ sock.once("connect", () => resolve(sock));
251
+ sock.once("error", reject);
252
+ });
253
+ raw.__smtpHost = host;
254
+ return wrapNodeSocket(raw);
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ async function openSocket(host: string, port: number): Promise<AnySocket> {
261
+ const specifier = "cloudflare:sockets";
262
+ const mod = (await import(/* @vite-ignore */ specifier).catch(
263
+ () => null,
264
+ )) as WorkerSocketsModule | null;
265
+ if (mod?.connect) {
266
+ return mod.connect(
267
+ { hostname: host, port },
268
+ { allowHalfOpen: false, secureTransport: "starttls" },
269
+ );
270
+ }
271
+ const node = await openNodeSocket(host, port);
272
+ if (node) return node;
273
+ throw new Error("Raw TCP sockets are unavailable in this runtime, so SMTP cannot be used here.");
274
+ }
275
+
276
+ class SmtpSession {
277
+ private reader: ReadableStreamDefaultReader<Uint8Array>;
278
+ private writer: WritableStreamDefaultWriter<Uint8Array>;
279
+ private decoder = new TextDecoder();
280
+ private encoder = new TextEncoder();
281
+ private buffer = "";
282
+
283
+ constructor(private socket: AnySocket) {
284
+ this.reader = socket.readable.getReader();
285
+ this.writer = socket.writable.getWriter();
286
+ }
287
+
288
+ static async open(host: string, port: number) {
289
+ return new SmtpSession(await openSocket(host, port));
290
+ }
291
+
292
+ async read(): Promise<SmtpReply> {
293
+ while (!isCompleteReply(this.buffer)) {
294
+ const { value, done } = await this.reader.read();
295
+ if (done) break;
296
+ if (value) this.buffer += this.decoder.decode(value, { stream: true });
297
+ }
298
+ const reply = parseReply(this.buffer);
299
+ this.buffer = "";
300
+ if (!reply.code) throw new Error("The mail server closed the connection unexpectedly.");
301
+ return reply;
302
+ }
303
+
304
+ async send(line: string): Promise<SmtpReply> {
305
+ await this.writer.write(this.encoder.encode(`${line}\r\n`));
306
+ return this.read();
307
+ }
308
+
309
+ async sendRaw(data: string): Promise<void> {
310
+ await this.writer.write(this.encoder.encode(data));
311
+ }
312
+
313
+ /** Upgrade to TLS after a 220 STARTTLS reply, replacing the reader/writer. */
314
+ async upgrade(): Promise<void> {
315
+ this.reader.releaseLock();
316
+ this.writer.releaseLock();
317
+ this.socket = this.socket.startTls();
318
+ this.reader = this.socket.readable.getReader();
319
+ this.writer = this.socket.writable.getWriter();
320
+ this.buffer = "";
321
+ }
322
+
323
+ async close(): Promise<void> {
324
+ try {
325
+ this.reader.releaseLock();
326
+ this.writer.releaseLock();
327
+ await this.socket.close();
328
+ } catch {
329
+ /* already closed */
330
+ }
331
+ }
332
+ }
333
+
334
+ function expect(reply: SmtpReply, ok: number[], stage: string): void {
335
+ if (!ok.includes(reply.code)) {
336
+ throw new Error(`${stage} failed — server said ${reply.code} ${reply.text}`);
337
+ }
338
+ }
339
+
340
+ function b64(value: string): string {
341
+ const bytes = new TextEncoder().encode(value);
342
+ let binary = "";
343
+ for (const b of bytes) binary += String.fromCharCode(b);
344
+ return btoa(binary);
345
+ }
346
+
347
+ async function deliver(config: SmtpConfig, msg: SmtpMessage): Promise<void> {
348
+ const clientDomain = msg.fromEmail.split("@")[1] || "localhost";
349
+ const session = await SmtpSession.open(config.host, config.port);
350
+ try {
351
+ expect(await session.read(), [220], "Connection");
352
+
353
+ let ehlo = await session.send(`EHLO ${clientDomain}`);
354
+ expect(ehlo, [250], "EHLO");
355
+
356
+ const supportsStartTls = ehlo.lines.some((l) => /STARTTLS/i.test(l));
357
+ if (supportsStartTls) {
358
+ expect(await session.send("STARTTLS"), [220], "STARTTLS");
359
+ await session.upgrade();
360
+ ehlo = await session.send(`EHLO ${clientDomain}`);
361
+ expect(ehlo, [250], "EHLO after STARTTLS");
362
+ } else if (config.port !== 25) {
363
+ throw new Error(
364
+ "The mail server does not advertise STARTTLS, so credentials cannot be sent securely.",
365
+ );
366
+ }
367
+
368
+ if (config.username && config.password) {
369
+ const authLine = ehlo.lines.find((l) => /AUTH\s/i.test(l)) ?? "";
370
+ if (/PLAIN/i.test(authLine) && !/LOGIN/i.test(authLine)) {
371
+ const token = b64(`\0${config.username}\0${config.password}`);
372
+ expect(await session.send(`AUTH PLAIN ${token}`), [235], "Authentication");
373
+ } else {
374
+ expect(await session.send("AUTH LOGIN"), [334], "Authentication");
375
+ expect(await session.send(b64(config.username)), [334], "Authentication (username)");
376
+ expect(await session.send(b64(config.password)), [235], "Authentication (password)");
377
+ }
378
+ }
379
+
380
+ expect(await session.send(`MAIL FROM:<${msg.fromEmail}>`), [250], "MAIL FROM");
381
+ expect(await session.send(`RCPT TO:<${msg.to}>`), [250, 251], "RCPT TO");
382
+ expect(await session.send("DATA"), [354], "DATA");
383
+ await session.sendRaw(`${buildMimeMessage(msg, clientDomain)}\r\n.\r\n`);
384
+ expect(await session.read(), [250], "Message delivery");
385
+
386
+ await session.send("QUIT").catch(() => undefined);
387
+ } finally {
388
+ await session.close();
389
+ }
390
+ }
391
+
392
+ export async function sendSmtp(config: SmtpConfig, msg: SmtpMessage): Promise<SmtpResult> {
393
+ const invalid = validateSmtpTarget(config.host, config.port);
394
+ if (invalid) return { ok: false, error: invalid };
395
+ if (config.username && !config.password) {
396
+ return { ok: false, error: "SMTP password secret is not available on the server." };
397
+ }
398
+ try {
399
+ await Promise.race([
400
+ deliver(config, msg),
401
+ new Promise<never>((_, reject) =>
402
+ setTimeout(
403
+ () => reject(new Error(`No response from ${config.host}:${config.port} within 15s.`)),
404
+ CONNECT_TIMEOUT_MS,
405
+ ),
406
+ ),
407
+ ]);
408
+ return { ok: true };
409
+ } catch (err) {
410
+ const base = err instanceof Error ? err.message : String(err);
411
+ const hint = /unavailable in this runtime|refused|not allowed|within 15s/i.test(base)
412
+ ? " If your host blocks outbound SMTP, use the Mailgun, Amazon SES or HTTP endpoint provider instead."
413
+ : "";
414
+ return { ok: false, error: `${base}${hint}` };
415
+ }
416
+ }
@@ -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
+ }