UtmBuilder
Builds a campaign link with consistent case, warns on spaces and mixed conventions, keeps the query and fragment, and remembers recent values.
Demo
Campaign link
Sent as newsletter
Sent as spring-sale-2026
- Source had capitals: it goes as newsletter, so it counts with the rest.
- Campaign has spaces: they become -, giving spring-sale-2026.
- Campaign had capitals: it goes as spring-sale-2026, so it counts with the rest.
- Campaign spring-sale-2026 is close to spring_sale_2026, used before. The same spelling keeps them together in reports.
Final link
https://example.com/sale?ref=7&utm_source=newsletter&utm_medium=email&utm_campaign=spring-sale-2026#buy
With on_change
- No https:// on the address, so it was added.
Final link
https://shop.example/shoes?utm_source=google&utm_medium=cpc&utm_campaign=shoes
Change a field to see on_change.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/utm_builder.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/utm_builder.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/utm_builder.qwe
component UtmBuilderDemoLive() { state seen = null <div.demo-stack> <UtmBuilder id="utm-builder-demo-live" title="With on_change" name="ad" url="shop.example/shoes" source="google" medium="cpc" campaign="shoes" on_change={(r) => { seen = r }}/> <p.demo-note>{seen == null ? "Change a field to see on_change." : `on_change: ok ${seen.ok}, ${seen.warnings} warnings, ${seen.link}.`}</p> </div>}view UtmBuilderDemo() = <div.demo-stack> <UtmBuilder id="utm-builder-demo" url="https://example.com/sale?ref=7#buy" source="Newsletter" medium="email" campaign="Spring Sale 2026" recent={{ source: ["newsletter", "partner-blog"], medium: ["email", "cpc"], campaign: ["spring_sale_2026"] }} remember="utm-builder-demo"/> <UtmBuilderDemoLive/> </div>Source
ui/utm_builder.qwe
// UtmBuilder: builds a campaign link that reports will group properly. It cleans each// value (one case, spaces turned into a separator) and shows what will be sent, warns of// what splits a report in two (Email and email, spring_sale beside spring-sale, a// spelling close to one used before, a medium no analytics tool groups), keeps the// address's own query and fragment where they are, replaces any utm_ already there, and// remembers recent values to pick from.//// <UtmBuilder url="https://example.com/sale?ref=7#buy" source="newsletter" medium="email"/>// <UtmBuilder recent={{ source: ["google"], medium: ["cpc"] }} remember="utm-recent"// on_change={(r) => { link = r.link }}/>//// url, source, medium, campaign, term, content: where the fields start. letter_case:// "lower" (the default) or "typed". separator: what spaces become, "-" (the default), "_"// or "keep" (sent as %20). recent: values to offer at the start, a map from each field to// a list. remember: a name to keep recent values under in this browser (localStorage),// after Copy or Keep; left out, they last as long as the page. most_recent: how many a// field keeps (8). title: its heading. id: a prefix for its ids, unique on the page. The// fields are named url, utm_source, utm_medium, utm_campaign, utm_term and utm_content,// so a form around it sends them; name: a prefix for those names. on_change(result):// inside one of your components, given { link, ok, warnings } after a change: link is// null until it can be built, warnings how many there are. class: more classes for its// outer element.//// utm_builder_clean(value, letter_case, separator) cleans one value, utm_builder_link(url,// values, letter_case, separator) gives { link, problems, notes }, and// utm_builder_warnings(values, letter_case, separator, recent) the warnings, for a// program's own use. values is { source, medium, campaign, term, content }.import ui/theme.qwe use ui_clslet utm_builder_css = css`.ui-utm { display: grid; gap: 12px; min-width: 0; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-utm .ui-utm-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-utm-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }.ui-utm-field { display: grid; gap: 4px; min-width: 0; align-content: start; }.ui-utm-field label { font-size: 13px; font-weight: 500; }.ui-utm-req { color: var(--ui-muted); font-weight: 400; }.ui-utm-input { box-sizing: border-box; width: 100%; height: 36px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); }.ui-utm-input:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-utm-input[aria-invalid] { border-color: var(--ui-danger); }.ui-utm-sent { color: var(--ui-muted); font: 12px var(--ui-mono); overflow-wrap: anywhere; }.ui-utm-recent { display: flex; flex-wrap: wrap; gap: 4px; }.ui-utm-pick { padding: 1px 7px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 12px/1.5 var(--ui-font); cursor: pointer; }.ui-utm-pick:hover { border-color: var(--ui-accent); }.ui-utm-pick:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-utm-options { display: flex; flex-wrap: wrap; gap: 12px; }.ui-utm-option { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }.ui-utm-option select { height: 30px; padding: 0 6px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 13px var(--ui-font); }.ui-utm-option select:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-utm-list { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; font-size: 13px; }.ui-utm-list li { padding: 6px 10px; border-left: 4px solid var(--ui-warning); border-radius: var(--ui-radius-sm); background: var(--ui-warning-soft); overflow-wrap: anywhere; }.ui-utm-list.ui-utm-bad li { border-left-color: var(--ui-danger); background: var(--ui-danger-soft); }.ui-utm-result { display: grid; gap: 6px; }.ui-utm-result h4 { margin: 0; font-size: 13px; font-weight: 600; }.ui-utm-link { margin: 0; padding: 8px 10px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); font: 13px/1.5 var(--ui-mono); overflow-wrap: anywhere; }.ui-utm-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }.ui-utm-btn { height: 30px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-utm-btn:hover { background: var(--ui-subtle); }.ui-utm-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-utm-note { color: var(--ui-muted); font-size: 13px; }`let utm_builder_fields = [{ id: "source", label: "Source", required: true, hint: "newsletter, google" }, { id: "medium", label: "Medium", required: true, hint: "email, cpc" }, { id: "campaign", label: "Campaign", required: true, hint: "spring-sale" }, { id: "term", label: "Term", required: false, hint: "paid keywords" }, { id: "content", label: "Content", required: false, hint: "header-link" }]let utm_builder_mediums = ["cpc", "ppc", "cpm", "cpv", "display", "banner", "email", "social", "paid-social", "paid_social", "organic", "affiliate", "referral", "retargeting", "sms", "push", "video", "audio", "print", "qr", "partner"]let utm_builder_aliases = { "e-mail": "email", mail: "email", newsletter: "email", "paid-search": "cpc", paid_search: "cpc", paidsearch: "cpc", adwords: "cpc", sem: "cpc", facebook: "social", instagram: "social", linkedin: "social", twitter: "social" }// One value, cleaned: trimmed, one case, spaces as the separator.fn utm_builder_clean(value: str, letter_case: str = "lower", separator: str = "-") -> str { let t = trim(value) let cased = letter_case == "lower" ? lower(t) : t separator == "keep" ? re.replace(cased, "\\s+", " ") : re.replace(cased, "\\s+", separator)}// How a value joins its words, or null if it has one word: "hyphen", "underscore",// "camel", or "mixed" with more than one.fn utm_builder_style(value: str) -> str? { let hyphen = contains(value, "-") let under = contains(value, "_") let camel = re.test(value, "[a-z][A-Z]") let n = count([hyphen, under, camel], (x) => x) n > 1 ? "mixed" : (hyphen ? "hyphen" : (under ? "underscore" : (camel ? "camel" : null)))}// A value with case and joining taken away, to see two spellings are one.fn utm_builder_core(value: str) -> str = re.replace(lower(value), "[\\s_-]+", "")// The warnings about the values, as text. See the top of the file.fn utm_builder_warnings(values: map, letter_case: str, separator: str, recent: map) -> list { let out = [] let filled_fields = utm_builder_fields | filter((f) => trim(get(values, f.id) ?? "") != "") let sent = (f) => utm_builder_clean(get(values, f.id), letter_case, separator) for f in filled_fields { let raw_value = trim(get(values, f.id)) let clean = sent(f) if re.test(raw_value, "\\s") { out = push(out, separator == "keep" ? `${f.label} has spaces, which show as %20 in the link and in reports.` : `${f.label} has spaces: they become ${separator}, giving ${clean}.`) } if letter_case == "lower" && lower(raw_value) != raw_value { out = push(out, `${f.label} had capitals: it goes as ${clean}, so it counts with the rest.`) } if letter_case == "typed" && re.test(raw_value, "[A-Z]") { out = push(out, `${f.label} has capitals: Email and email would count as two sources in reports.`) } if utm_builder_style(clean) == "mixed" { out = push(out, `${f.label} joins words in more than one way: ${clean}. Pick one.`) } if re.test(clean, "[^\\w. ~-]") { out = push(out, `${f.label} has characters that get encoded: ${clean}. Letters, digits, - and _ read best in reports.`) } let like = find(get(recent, f.id) ?? [], (r) => r != clean && utm_builder_core(r) == utm_builder_core(clean)) if like != null { out = push(out, `${f.label} ${clean} is close to ${like}, used before. The same spelling keeps them together in reports.`) } } let styles = unique(filled_fields | map((f) => utm_builder_style(sent(f))) | filter((s) => s != null && s != "mixed")) if len(styles) > 1 { out = push(out, `The fields join words differently: some with ${join(styles | map(utm_builder_style_word), ", some with ")}. Reports read better with one.`) } let medium = utm_builder_clean(values.medium ?? "", letter_case, separator) let low_medium = lower(medium) if medium != "" { let alias = get(utm_builder_aliases, low_medium) if alias != null { out = push(out, `Medium ${medium}: analytics tools group this under ${alias}.`) } else if !contains(utm_builder_mediums, low_medium) { out = push(out, `Medium ${medium} is not one analytics tools group by: the usual are cpc, email, social, display, referral, affiliate.`) } } let source = utm_builder_clean(values.source ?? "", letter_case, separator) if source != "" && lower(source) == low_medium { out = push(out, `Source and medium are both ${source}: source says who sent the visit, medium how.`) } out}fn utm_builder_style_word(s: str) -> str = match s { "hyphen" => "hyphens", "underscore" => "underscores", _ => "capitals" }// The link. problems stop it; notes do not.fn utm_builder_link(url: str, values: map, letter_case: str, separator: str) -> map { let problems = [] let notes = [] let address = trim(url) if address == "" { return { link: null, problems: ["Enter the page's address."], notes } } if len(address) > 8000 { return { link: null, problems: [`The address is ${format_number(len(address), "en-US", 0)} characters, past the 8,000 it will read.`], notes } } if re.test(address, "\\s") { return { link: null, problems: ["The address has a space in it: encode it as %20 or take it out."], notes } } if starts_with(address, "/") || starts_with(address, "?") || starts_with(address, "#") { return { link: null, problems: ["A campaign link goes in emails and ads, so it needs the whole address, with https://."], notes } } let schemed = re.test(address, "^[a-z][a-z0-9+.-]*://", "i") if !schemed && re.test(address, "^[a-z][a-z0-9+.-]*:", "i") && !re.test(address, "^[^:/]+:\\d") { return { link: null, problems: ["This is not a web address: it must start with http:// or https://."], notes } } if !schemed { notes = push(notes, "No https:// on the address, so it was added.") } let full = schemed ? address : `https://${address}` let m = re.find(full, "^([a-z][a-z0-9+.-]*)://([^/?#]*)([^?#]*)(?:\\?([^#]*))?(#.*)?$", "i") if m == null || m.groups[1] == "" { return { link: null, problems: ["The address has no host."], notes } } if !re.test(m.groups[0], "^https?$", "i") { return { link: null, problems: ["This is not a web address: it must start with http:// or https://."], notes } } if !re.test(m.groups[1], "\\.|^localhost(:|$)|^\\[") { notes = push(notes, `The host ${m.groups[1]} has no dot: check the address.`) } let head = `${m.groups[0]}://${m.groups[1]}${m.groups[2]}` let given = utm_builder_fields | filter((f) => trim(get(values, f.id) ?? "") != "") let names = given | map((f) => `utm_${f.id}`) let pieces = split(m.groups[3] ?? "", "&") | filter((p) => p != "") let key_of = (p) => lower(split(p, "=")[0]) let replaced = pieces | filter((p) => contains(names, key_of(p))) let left_utm = pieces | filter((p) => starts_with(key_of(p), "utm_") && !contains(names, key_of(p))) let kept = pieces | filter((p) => !contains(names, key_of(p))) for p in replaced { notes = push(notes, `The address already had ${p}: replaced.`) } for p in left_utm { notes = push(notes, `The address keeps ${p}, as no value here replaces it.`) } let fragment = m.groups[4] ?? "" if contains(fragment, "?") { notes = push(notes, "The fragment holds a ?, as a hash route does. The parameters go before the #, where analytics tools read them.") } let added = given | map((f) => `utm_${f.id}=${url_encode(utm_builder_clean(get(values, f.id), letter_case, separator))}`) let all_parts = kept + added let link = `${head}${len(all_parts) > 0 ? `?${join(all_parts, "&")}` : ""}${fragment}` if len(link) > 2000 { notes = push(notes, `The link is ${format_number(len(link), "en-US", 0)} characters: some tools cut links past 2,000.`) } { link, problems, notes }}// A value at the front of its list, once.fn utm_builder_add(past: map, field: str, value: str, most: int) -> map = value == "" ? past : set(past, field, take([value] + remove(get(past, field) ?? [], value), most))// What localStorage held, kept only if it is what was put there.fn utm_builder_read_store(text) -> map { let v = type_of(text) == "str" ? json.parse(text) : null if type_of(v) != "map" { return {} } reduce(utm_builder_fields, {}, (m, f) => { let xs = get(v, f.id) type_of(xs) == "list" ? set(m, f.id, xs | filter((x) => type_of(x) == "str")) : m })}component UtmBuilder(url: str = "", source: str = "", medium: str = "", campaign: str = "", term: str = "", content: str = "", letter_case: str = "lower", separator: str = "-", recent = {}, remember: str? = null, most_recent: int = 8, title: str = "Campaign link", id: str = "utm-builder", name: str? = null, on_change = null, class: str? = null) { style global utm_builder_css state address = url state vals = { source, medium, campaign, term, content } state how_case = letter_case state how_space = separator state past = type_of(recent) == "map" ? recent : {} state copied = "" let named = (n: str) => name != null ? `${name}_${n}` : n let built = utm_builder_link(address, vals, how_case, how_space) let missing = utm_builder_fields | filter((f) => f.required && trim(get(vals, f.id) ?? "") == "") let warns = utm_builder_warnings(vals, how_case, how_space, past) let problems = built.problems + (missing | map((f) => `${f.label} is needed.`)) let ready = built.link != null && len(missing) == 0 let tell = () => { if on_change != null { let b = utm_builder_link(address, vals, how_case, how_space) let gone = count(utm_builder_fields, (f) => f.required && trim(get(vals, f.id) ?? "") == "") on_change({ link: gone == 0 ? b.link : null, ok: b.link != null && gone == 0, warnings: len(utm_builder_warnings(vals, how_case, how_space, past)) + len(b.notes) }) } } let keep = () => { let next = reduce(utm_builder_fields, past, (m, f) => utm_builder_add(m, f.id, utm_builder_clean(get(vals, f.id) ?? "", how_case, how_space), most_recent)) past = next if remember != null { js("localStorage.setItem", remember, json.stringify(next)) } } let copy = () => { keep() js("navigator.clipboard.writeText", built.link).then((_ = null) => { copied = "Copied" }, (_ = null) => { copied = "Could not copy: select the link instead" }) } <section class={ui_cls(["ui-utm", class])} aria-labelledby={`${id}-title`} @mount={() => { if remember != null { let stored = utm_builder_read_store(js("localStorage.getItem", remember)) past = reduce(keys(stored), past, (m, k) => set(m, k, take(unique(get(stored, k) + (get(m, k) ?? [])), most_recent))) } }}> <h3.ui-utm-title id={`${id}-title`}>{title}</h3> <div.ui-utm-field> <label for={`${id}-url`}>Page address</label> <input.ui-utm-input type="text" inputmode="url" id={`${id}-url`} name={named("url")} value={address} spellcheck="false" autocomplete="off" placeholder="https://example.com/sale" aria-invalid={len(built.problems) > 0 && trim(address) != "" ? "true" : null} @input={(e) => { address = e.value; copied = ""; tell() }}/> </div> <div.ui-utm-grid> {for f in utm_builder_fields => { let value = get(vals, f.id) ?? "" let clean = utm_builder_clean(value, how_case, how_space) let picks = take(get(past, f.id) ?? [], 5) <div.ui-utm-field key={f.id}> <label for={`${id}-${f.id}`}>{f.label}{f.required ? "" : " "}{!f.required && <span.ui-utm-req>(optional)</span>}</label> <input.ui-utm-input type="text" id={`${id}-${f.id}`} name={named(`utm_${f.id}`)} value={value} list={`${id}-${f.id}-list`} spellcheck="false" autocomplete="off" placeholder={f.hint} @input={(e) => { vals = set(vals, f.id, e.value); copied = ""; tell() }}/> <datalist id={`${id}-${f.id}-list`}>{for r in get(past, f.id) ?? [] => <option key={r} value={r}></option>}</datalist> {clean != value && trim(value) != "" && <span.ui-utm-sent data-sent={f.id}>{`Sent as ${clean}`}</span>} {len(picks) > 0 && <div.ui-utm-recent role="group" aria-label={`Recent ${lower(f.label)} values`}> {for r in picks => <button.ui-utm-pick key={r} type="button" @click={() => { vals = set(vals, f.id, r); copied = ""; tell() }}>{r}</button>} </div>} </div> }} </div> <div.ui-utm-options> <label.ui-utm-option>Case <select name={named("letter_case")} @change={(e) => { how_case = e.value; tell() }}> <option value="lower" selected={how_case == "lower"}>all lower case</option> <option value="typed" selected={how_case == "typed"}>as typed</option> </select> </label> <label.ui-utm-option>Spaces become <select name={named("separator")} @change={(e) => { how_space = e.value; tell() }}> <option value="-" selected={how_space == "-"}>hyphens</option> <option value="_" selected={how_space == "_"}>underscores</option> <option value="keep" selected={how_space == "keep"}>stay as spaces</option> </select> </label> </div> {len(problems) > 0 && <ul class="ui-utm-list ui-utm-bad" data-problems>{for p in problems => <li key={p}>{p}</li>}</ul>} {len(warns) + len(built.notes) > 0 && <ul.ui-utm-list data-warnings aria-live="polite"> {for w in warns + built.notes => <li key={w}>{w}</li>} </ul>} {ready && <div.ui-utm-result> <h4 id={`${id}-link-label`}>Final link</h4> <p.ui-utm-link data-link aria-labelledby={`${id}-link-label`} aria-live="polite">{built.link}</p> <div.ui-utm-actions> <button.ui-utm-btn type="button" data-copy @click={copy}>Copy</button> <button.ui-utm-btn type="button" data-keep @click={keep}>Keep values</button> <span.ui-utm-note aria-live="polite">{copied}</span> </div> </div>} </section>}Related
FunnelAnalyzerStep counts to drop-off, conversion and the biggest leak, with a what-if on any step carried through to the end, and a warning when a step grows.2 views · 6 downloadsAbTestCalcReads an A/B test from visitors and conversions: lift, z-test, p-value, interval and a plain verdict, refusing to call too small a sample.2 views · 7 downloadsMailMergeFills a template with {{name}} and fallbacks from CSV or JSON rows: a preview for each row, the missing and unused variables, and the rows that would send an empty value.3 views · 8 downloadsSampleSizeCalcVisitors per variant and days to run for an A/B test, with the formula shown, refusing inputs that have no answer.3 views · 6 downloadsSlugInputMakes a URL slug from a title as it is typed, writing accents plainly, stops following once edited by hand, and checks taken and reserved links, offering the next free one.3 views · 6 downloadsAnnouncementBarA slim bar at the top with one message, a badge and a link, closed for good once dismissed.6 views · 5 downloads
Thread
Thread 0 posts
On UtmBuilder: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.