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 →