String Normalization
SkillDev toolsEsposter string normalization and HTML sanitization conventions — pluralize over a hand-rolled count ternary; normalizeString is the default trim in app code and in base Zod schemas (never in Vue — the vue skill owns that); sanitizeTextHtml is declared at the Zod boundary in base db-schema schemas (never manual frontend calls). Exceptions — user-facing transformation actions, standalone packages, and localStorage drafts. Also covers matching or rewriting a token inside authored content (urls, merge fields, blueprint aliases) — opener-anchored matching over negated charsets, walking string leaves instead of the serialized form, one pass keyed by a Map, and widening the reader instead of backfilling.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the String Normalization skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/string-utils/SKILL.md and read by ahel’s review.
normalizeString
Trims whitespace and returns empty string for absent/null/undefined inputs. Lives in @esposter/shared.
import { normalizeString } from "@esposter/shared";
normalizeString(" hello "); // → "hello"
normalizeString(null); // → ""
normalizeString(undefined); // → ""
pluralize
${count} ${pluralize("result", count)}. The hand-rolled ${count === 1 ? "" : "s"} is a no-restricted-syntax error in script and template alike, and this is what it is banned for: pluralize lives in #shared/util/text/pluralize and selects through EN_US_PLURAL_RULES (Intl.PluralRules), so the ternary is not even equivalent: the rules object is what decides, and it is the seam a non-English locale changes. The inline ternary also gets written per surface and drifts — the same count is "1 result" here and "1 results" there.
The selector is the literal ""/"s" pair and nothing about what surrounds it, so it catches a suffix built in a variable as readily as one written next to the count — and it catches a non-plural s too, as in http${isSecure ? "s" : ""}, which disables the rule on the line with that reason. Narrowing it to a plural context is not available: neither the count nor the word is in the node.
pluralize("result"); // → "results" (count defaults to 2)
pluralize("result", count);
Convention: string not string | null
Optional text fields use empty string as the "absent" sentinel — never null:
- DB column:
text().notNull().default("") - TypeScript type:
string(notstring | null) - Zod schema:
createNormalizedStringSchema(N, schema)in the baseselectXxxSchema(never in derived schemas)
When to use normalizeString
The default trim in app code — reach for it over a bare .trim():
- Parsing (CSV, XLSX, clipboard):
normalizeString(cell?.toString()) - Array mapping:
values.map(normalizeString).filter(Boolean) - Guard checks:
if (!normalizeString(value)) return; - Filter predicates:
.filter((line) => normalizeString(line) !== "") - Zod schemas: see Zod Schema Alignment below
When NOT to use normalizeString
- Never anywhere in Vue — not in
@update:model-value, not in submit handlers. The tRPC Zod boundary already normalizes; in@update:model-valueit actively harms (trims mid-typing, swallows spaces). See thevueskill (normalizeStringNever in Vue) — it owns this rule. - User-facing transformation actions — e.g.
computeStringTransformation.tsTrimcase; keepvalue.trim(), it's implementing a named user operation. - The
normalizeStringfunction itself — obviously. - Standalone published packages (
virrun,xml2js) —.trim()is live there and correct: it trims process stdout or implements xml2js's owntrimoption, none of which is user input crossing a Zod boundary.
.trimStart() and .trimEnd() are separate methods — replace only when semantically equivalent to a full .trim().
Preserve undefined when needed
In a non-form composable where normalizeString is allowed (e.g. useAutoSearch), when the old value in a watch callback must stay undefined to signal "first render" (distinct from an empty string that was previously seen):
const sanitizedOld = oldValue !== undefined ? normalizeString(oldValue) : oldValue;
Zod Schema Alignment
Base select schemas normalize so server validation matches client input. Always transform first, then validators in the pipe. Never add trim transforms to derived schemas (UpdateFooInput, etc.) — only in the base select schema.
Prefer the shared schema helpers over hand-rolling the transform+pipe — see the zod skill.
// db-schema createSelectSchema overrides — the canonical form
foo: (schema) => createNormalizedStringSchema(FOO_MAX_LENGTH, schema),
Matching a Token Inside Authored Content
Before writing or widening any regex that finds something inside content a user authored (a blob url, a {{variable}}, a blueprint alias), read apps/web/content/docs/architecture/content-token-rewriting.md — it is canonical. The four rules that are broken:
- Never define the match as a negated charset (
[^"'()<>\s\\]*) — "everything except the delimiters I thought of" is a guess at a set that is never closed. Either the token carries its own delimiters ({{…}}), or anchor the match on the delimiter that opened it via lookbehind, so each context permits the characters the others reserve. An opener the content escapes (an html-escaped quote) is still an opener, and a position with no recognised opener falls back to the conservative body — a fallback reachable from any position, never from an enumerated set of preceding characters, which silently matches nothing after every character the list forgets. - Walk the parsed value's string leaves, never regex its serialized form — use
deepReplaceStrings(#shared/util/object/deepReplaceStrings) rather than matching overJSON.stringify(content), which makes the matcher read the serializer's escaping on top of the content's own. - One pass keyed by a
Map, never a per-token regex loop over the whole document — a loop lets a token consume a longer token it is a prefix of, and scales cost with tokens × content size. - Widen the reader, don't backfill, when a token's canonical form changes: content is rewritten on every read, so it converges on its own.
HTML Sanitization at the Zod Boundary
Same principle as normalizeString: user-authored rich-text HTML (messages, post/comment descriptions, todo notes) is sanitized once, in the base Zod schema via .transform(sanitizeTextHtml) — never with manual sanitizeTextHtml(...) calls on the frontend. Declaring it in the schema is the contract; the server enforces it during input validation, so the client never needs to re-sanitize or re-validate.
sanitizeHtmlandsanitizeTextHtmllive in@esposter/shared(sodb-schemaschemas can import them).sanitizeHtmlis the generic wrapper (table styling);sanitizeTextHtmladds the rich-text allowlist (mentions, code, links, inline styles).- Applied to every rich-text field in the base
db-schemamodel, transform-first then validators:
Derived input schemas (// the base select schema foo: z.string().transform(sanitizeTextHtml).pipe(z.string().max(FOO_MAX_LENGTH)),UpdateFooInput, …).pick()these fields and inherit the transform — never re-declare it. - No frontend sanitize on the send path.
createMessage/updateMessagepass rawinputto the mutation; the zod boundary sanitizes. The brief optimistic render of your own message is self-XSS only (you typed it) and is replaced by the sanitized server echo. - Exception — localStorage drafts:
setDraftstill callssanitizeTextHtmlbecause drafts are loaded into the editor without passing through a tRPC zod boundary. - Testing: only the base
sanitizeHtml/sanitizeTextHtmlfunctions are unit-tested (in@esposter/shared). Schema wiring needs no test — declaring the transform is the contract.marked.parseis third-party and untested.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
string-utils- Source
- github.com/esposter/esposter