File size: 6,954 Bytes
71993f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
/* UTM Builder core (pure, no DOM). © 2026 CyberMax. All rights reserved. */
(function (root) {
  "use strict";

  var KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_id", "utm_term", "utm_content"];
  // Mediums that GA4's default channel groups recognise (patterns from Google's published rules, simplified).
  var KNOWN_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|e[-_ ]?mail|display|banner|expandable|interstitial|affiliate|referral|app|link|social|social[-_ ]network|social[-_ ]media|sm|organic|video|audio|sms|push|mobile|notification|qr|print|offline)$/;

  var PRESETS = {
    "Google Ads": { utm_source: "google", utm_medium: "cpc" },
    "Microsoft Ads": { utm_source: "bing", utm_medium: "cpc" },
    "Meta Ads": { utm_source: "facebook", utm_medium: "paid_social" },
    "Instagram bio": { utm_source: "instagram", utm_medium: "social" },
    "LinkedIn post": { utm_source: "linkedin", utm_medium: "social" },
    "X / Twitter": { utm_source: "twitter", utm_medium: "social" },
    "TikTok": { utm_source: "tiktok", utm_medium: "social" },
    "YouTube": { utm_source: "youtube", utm_medium: "video" },
    "Newsletter": { utm_source: "newsletter", utm_medium: "email" },
    "QR / print": { utm_source: "qr", utm_medium: "print" },
    "Affiliate": { utm_source: "partner", utm_medium: "affiliate" }
  };

  function clean(v, opts) {
    v = String(v == null ? "" : v).trim();
    if (opts.lower) v = v.toLowerCase();
    if (opts.space && opts.space !== "keep") v = v.replace(/\s+/g, opts.space);
    return v;
  }

  function normalizeBase(base) {
    base = String(base || "").trim();
    if (!base) return { error: "Enter the page URL you want to track." };
    var added = false;
    if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(base)) { base = "https://" + base; added = true; }
    var u;
    try { u = new URL(base); } catch (e) { return { error: "That doesn't look like a valid URL." }; }
    if (!/^https?:$/.test(u.protocol)) return { error: "Use an http:// or https:// URL." };
    if (!/\./.test(u.hostname) && u.hostname !== "localhost") return { error: "That URL has no domain name." };
    return { url: u, added: added };
  }

  // Build a tagged URL. params: {utm_source, ...}; opts: {lower: bool, space: "-"|"_"|"%20"|"keep"}.
  function build(base, params, opts) {
    opts = opts || { lower: true, space: "-" };
    var warnings = [];
    var nb = normalizeBase(base);
    if (nb.error) return { url: "", error: nb.error, warnings: [] };
    var u = nb.url;
    if (nb.added) warnings.push("Added https:// to the URL.");
    var replaced = [];
    var kept = [];
    u.search.replace(/^\?/, "").split("&").forEach(function (pair) {
      if (!pair) return;
      var k = decodeURIComponent(pair.split("=")[0].replace(/\+/g, " ")).toLowerCase();
      if (KEYS.indexOf(k) >= 0) replaced.push(k); else kept.push(pair);
    });
    var tags = [];
    KEYS.forEach(function (k) {
      var raw = params[k];
      var v = clean(raw, opts);
      if (v) tags.push(k + "=" + encodeURIComponent(v).replace(/%2F/gi, "/"));
      if (raw && /\s/.test(String(raw).trim()) && opts.space === "keep") warnings.push(k + " contains spaces; they'll be encoded as %20.");
      if (raw && !opts.lower && /[A-Z]/.test(raw)) warnings.push(k + " has capitals. Analytics treats \"Facebook\" and \"facebook\" as different sources.");
    });
    ["utm_source", "utm_medium", "utm_campaign"].forEach(function (k) {
      if (!clean(params[k], opts)) warnings.push(k + " is empty. GA4 needs source, medium and campaign to report the visit properly.");
    });
    var med = clean(params.utm_medium, opts);
    if (med && !KNOWN_MEDIUM.test(med.toLowerCase())) warnings.push("utm_medium \"" + med + "\" isn't one GA4's default channel groups recognise, so these visits may show as \"Unassigned\". Try cpc, email, social, paid_social, display, affiliate or referral.");
    if (replaced.length) warnings.push("Replaced existing " + replaced.join(", ") + " in the URL.");
    var search = kept.concat(tags).join("&");
    var out = u.origin + u.pathname + (search ? "?" + search : "") + u.hash;
    if (out.length > 2000) warnings.push("The URL is over 2,000 characters; some platforms truncate long links.");
    return { url: out, warnings: warnings, tagged: tags.length > 0 };
  }

  function parse(url) {
    var nb = normalizeBase(url);
    if (nb.error) return { error: nb.error };
    var u = nb.url, utm = {}, other = [];
    u.searchParams.forEach(function (v, k) {
      if (/^utm_/i.test(k)) utm[k.toLowerCase()] = v; else other.push(k + "=" + v);
    });
    var cleanUrl = u.origin + u.pathname;
    var rest = [];
    u.search.replace(/^\?/, "").split("&").forEach(function (p) { if (p && !/^utm_/i.test(p)) rest.push(p); });
    if (rest.length) cleanUrl += "?" + rest.join("&");
    cleanUrl += u.hash;
    return { utm: utm, clean: cleanUrl, other: other };
  }

  // CSV line splitter with quotes.
  function splitCsv(line) {
    var out = [], cur = "", q = false;
    for (var i = 0; i < line.length; i++) {
      var c = line[i];
      if (q) { if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; } else if (c === '"') q = false; else cur += c; }
      else if (c === '"') q = true;
      else if (c === "," || c === "\t") { out.push(cur); cur = ""; }
      else cur += c;
    }
    out.push(cur);
    return out.map(function (s) { return s.trim(); });
  }

  // Bulk: each line "url" or "url,source,medium,campaign,term,content". Blank columns use defaults.
  var BULK_COLS = ["url", "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"];
  function bulk(text, defaults, opts) {
    var lines = String(text || "").split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
    var cols = BULK_COLS;
    if (lines.length && /^url\b/i.test(lines[0])) {
      cols = splitCsv(lines.shift()).map(function (h) { h = h.toLowerCase().replace(/^utm_/, ""); return h === "url" ? "url" : "utm_" + h; });
    }
    return lines.slice(0, 5000).map(function (line) {
      var cells = splitCsv(line), p = {};
      KEYS.forEach(function (k) { p[k] = defaults[k] || ""; });
      var url = "";
      cols.forEach(function (c, i) { if (c === "url") url = cells[i] || ""; else if (cells[i]) p[c] = cells[i]; });
      var r = build(url, p, opts);
      return { input: url, url: r.url, error: r.error || "", warnings: r.warnings || [] };
    });
  }

  function csvCell(s) { s = String(s == null ? "" : s); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
  function toCsv(rows) {
    return "input_url,tagged_url,error\n" + rows.map(function (r) { return [r.input, r.url, r.error].map(csvCell).join(","); }).join("\n") + "\n";
  }

  var api = { KEYS: KEYS, PRESETS: PRESETS, build: build, parse: parse, bulk: bulk, toCsv: toCsv, splitCsv: splitCsv };
  root.UtmCore = api;
  if (typeof module !== "undefined") module.exports = api;
})(typeof window !== "undefined" ? window : globalThis);