Skip to content

UTM Link Builder — Go source

Build campaign tracking URLs with utm_source, utm_medium and utm_campaign parameters. Bulk mode processes a whole list, presets and import round-trip existing tracking URLs, and a validator flags attribution-breaking values — runs entirely in your browser.

This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package utmlinkbuilder is the Go twin of CosmoDev's src/lib/utm.ts (dual
// source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// Pure + deterministic, never panics. The table-driven tests in
// utm-link-builder_test.go share vectors with src/lib/utm.test.ts so the two
// implementations are held to the same contract.
//
// Semantics mirrored from the TS lib exactly: an unparseable base URL returns
// "" for Build (null in TS); stale utm_* params are stripped from the base
// while unrelated query params stay in place; the five campaign params are
// appended in canonical order; Lint reports GA4 naming conventions rather
// than hard failures.
package utmlinkbuilder

import (
	"fmt"
	"net/url"
	"strings"
)

// Params is the campaign parameter set for Build. The zero value means "no
// params" — empty-string fields are omitted from the built URL, matching the
// TS lib's omit-empty semantics for undefined/”.
type Params struct {
	Source   string
	Medium   string
	Campaign string
	Term     string
	Content  string
}

// LintLevel is the severity of a LintFinding.
type LintLevel string

const (
	LevelWarn  LintLevel = "warn"
	LevelError LintLevel = "error"
)

// LintCode is the category of a LintFinding.
type LintCode string

const (
	CodeConvention LintCode = "convention"
	CodeMissing    LintCode = "missing"
	CodeUnknown    LintCode = "unknown"
	CodeInvalidURL LintCode = "invalid-url"
)

// LintFinding is one GA4 naming-convention issue on a URL.
type LintFinding struct {
	Param   string
	Level   LintLevel
	Code    LintCode
	Message string
}

// BulkLine is one non-blank input line plus its built campaign URL.
// Output "" means the line did not parse as a URL (null in TS).
type BulkLine struct {
	Input  string
	Output string
}

// canonical is the utm_* parameter order used on every build. Same entries,
// same order, as CANONICAL in src/lib/utm.ts.
var canonical = []struct {
	field    string // Params field name, for documentation parity with TS
	queryKey string
	value    func(Params) string
}{
	{"source", "utm_source", func(p Params) string { return p.Source }},
	{"medium", "utm_medium", func(p Params) string { return p.Medium }},
	{"campaign", "utm_campaign", func(p Params) string { return p.Campaign }},
	{"term", "utm_term", func(p Params) string { return p.Term }},
	{"content", "utm_content", func(p Params) string { return p.Content }},
}

// knownUtmKeys is the set of utm_* keys we know about; anything else in a
// linted URL is flagged. Mirrors KNOWN_UTM_KEYS (six keys incl. utm_id).
var knownUtmKeys = map[string]bool{
	"utm_source":   true,
	"utm_medium":   true,
	"utm_campaign": true,
	"utm_term":     true,
	"utm_content":  true,
	"utm_id":       true,
}

// parseURL parses a base URL, coercing a scheme-less host into https. It
// returns ok=false when the input is hopeless. Go's url.Parse accepts almost
// anything (a bare path is a valid relative reference), so a parse only counts
// when it yields an absolute URL with a scheme and a host — the same cases
// where TS's `new URL(base)` succeeds.
func parseURL(base string) (*url.URL, bool) {
	for _, candidate := range []string{base, "https://" + base} {
		u, err := url.Parse(candidate)
		if err == nil && u.Scheme != "" && u.Host != "" {
			return u, true
		}
	}
	return nil, false
}

// queryToken is one raw key=value token of a query string.
type queryToken struct {
	rawKey string // token before the first '=', verbatim
	key    string // decoded key (falls back to rawKey on a decode error)
	value  string // decoded value
}

// parseQuery splits a raw query string on '&' preserving order and decoding
// keys/values, mirroring URLSearchParams iteration. A token without '=' is a
// name-only entry with an empty value.
func parseQuery(rawQuery string) []queryToken {
	if rawQuery == "" {
		return nil
	}
	tokens := strings.Split(rawQuery, "&")
	out := make([]queryToken, 0, len(tokens))
	for _, tok := range tokens {
		qt := queryToken{rawKey: tok}
		if key, val, found := strings.Cut(tok, "="); found {
			qt.rawKey = key
			qt.value = unescapeOrRaw(val)
		}
		qt.key = unescapeOrRaw(qt.rawKey)
		out = append(out, qt)
	}
	return out
}

// unescapeOrRaw query-unescapes s, returning s unchanged on a decode error.
func unescapeOrRaw(s string) string {
	if decoded, err := url.QueryUnescape(s); err == nil {
		return decoded
	}
	return s
}

// Build creates a campaign URL: strip any stale utm_* params from the base,
// keep every unrelated query param in place, then append the given params in
// canonical order (source, medium, campaign, term, content), skipping empty
// values. The hash fragment stays last. It returns "" for an unparseable base
// (null in the TS lib).
func Build(baseUrl string, params Params) string {
	u, ok := parseURL(baseUrl)
	if !ok {
		return ""
	}

	// Keep non-utm tokens verbatim (order + encoding preserved), drop utm_*.
	var kept []string
	for _, qt := range parseQuery(u.RawQuery) {
		if !strings.HasPrefix(qt.key, "utm_") {
			// Re-serialize like URLSearchParams does once mutated (TS keeps
			// the pair, re-encoded, in place).
			kept = append(kept, qt.rawKey+"="+url.QueryEscape(qt.value))
		}
	}

	for _, c := range canonical {
		if v := c.value(params); v != "" {
			kept = append(kept, c.queryKey+"="+url.QueryEscape(v))
		}
	}
	u.RawQuery = strings.Join(kept, "&")

	// WHATWG URLs always carry a path for host-bearing schemes ("https://x"
	// serializes as "https://x/"); Go omits the empty path, so normalize.
	if u.Path == "" && u.RawPath == "" && u.Opaque == "" {
		u.Path = "/"
	}
	return u.String()
}

// Lint reports GA4 naming-convention issues on a URL: utm_campaign with
// spaces or uppercase, missing utm_source/utm_medium, and unknown utm_* params.
// Findings are ordered unknown (query order) → convention → missing, matching
// the TS lib.
func Lint(rawURL string) []LintFinding {
	u, ok := parseURL(rawURL)
	if !ok {
		return []LintFinding{{
			Param:   "",
			Level:   LevelError,
			Code:    CodeInvalidURL,
			Message: "This does not parse as a URL.",
		}}
	}

	tokens := parseQuery(u.RawQuery)
	var findings []LintFinding

	for _, qt := range tokens {
		if !strings.HasPrefix(qt.key, "utm_") {
			continue
		}
		if !knownUtmKeys[qt.key] {
			findings = append(findings, LintFinding{
				Param:   qt.key,
				Level:   LevelWarn,
				Code:    CodeUnknown,
				Message: fmt.Sprintf("Unknown tracking parameter %s.", qt.key),
			})
		}
	}

	var campaign *string
	for i := range tokens {
		if tokens[i].key == "utm_campaign" {
			campaign = &tokens[i].value
			break
		}
	}
	if campaign != nil && (*campaign != strings.ToLower(*campaign) || strings.Contains(*campaign, " ")) {
		findings = append(findings, LintFinding{
			Param:   "utm_campaign",
			Level:   LevelWarn,
			Code:    CodeConvention,
			Message: "GA4 convention is lowercase with hyphens, no spaces.",
		})
	}

	for _, key := range []string{"utm_source", "utm_medium"} {
		present := false
		for _, qt := range tokens {
			if qt.key == key {
				present = true
				break
			}
		}
		if !present {
			findings = append(findings, LintFinding{
				Param:   key,
				Level:   LevelWarn,
				Code:    CodeMissing,
				Message: fmt.Sprintf("%s is recommended for GA4 attribution.", key),
			})
		}
	}

	return findings
}

// Bulk applies Build to each non-blank line; blank lines are skipped.
func Bulk(text string, params Params) []BulkLine {
	var out []BulkLine
	for _, line := range strings.Split(text, "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		out = append(out, BulkLine{Input: line, Output: Build(line, params)})
	}
	return out
}

// Presets are common campaign presets — partial params the user fills the rest
// of. Mirrors PRESETS in src/lib/utm.ts.
var Presets = map[string]Params{
	"newsletter":  {Medium: "email"},
	"paid-social": {Medium: "cpc"},
	"social":      {Medium: "social"},
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →