| name | string-utils |
| description | Esposter string normalization and HTML sanitization conventions — 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. |
String Normalization
normalizeString
Trims whitespace and returns empty string for absent/null/undefined inputs. Lives in @esposter/shared.
import { normalizeString } from "@esposter/shared";
normalizeString(" hello ");
normalizeString(null);
normalizeString(undefined);
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 (not string | null)
- Zod schema:
createNormalizedStringSchema(N, schema) in the base selectXxxSchema (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-value it actively harms (trims mid-typing, swallows spaces). See the vue skill (normalizeString Never in Vue) — it owns this rule.
- User-facing transformation actions — e.g.
computeStringTransformation.ts Trim case; keep value.trim(), it's implementing a named user operation.
- The
normalizeString function itself — obviously.
- Standalone published packages (
virrun, xml2js) — .trim() is live there and correct: it trims process stdout or implements xml2js's own trim option, 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 (UpdateRoomInput, UpdateSurveyInput, etc.) — only in the base select schema.
Prefer the shared helpers over hand-rolling the transform+pipe: createNormalizedStringSchema(maxLength, schema?) from @esposter/shared and createNameSchema(maxLength) from @esposter/db-schema (see the zod skill).
topic: (schema) => createNormalizedStringSchema(ROOM_TOPIC_MAX_LENGTH, schema),
nickname: (schema) => createNormalizedStringSchema(NICKNAME_MAX_LENGTH, schema),
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.
sanitizeHtml and sanitizeTextHtml live in @esposter/shared (so db-schema schemas can import them). sanitizeHtml is the generic wrapper (table styling); sanitizeTextHtml adds the rich-text allowlist (mentions, code, links, inline styles).
- Applied to every rich-text field in the base
db-schema model, transform-first then validators:
message: z.string().transform(sanitizeTextHtml).pipe(z.string().max(MESSAGE_MAX_LENGTH)).default(""),
description: (schema) => schema.transform(sanitizeTextHtml).pipe(z.string().max(POST_DESCRIPTION_MAX_LENGTH)),
Derived input schemas (UpdateMessageInput, ScheduleMessageInput, …) .pick() these fields and inherit the transform — never re-declare it.
- No frontend sanitize on the send path.
createMessage/updateMessage pass raw input to 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:
setDraft still calls sanitizeTextHtml because drafts are loaded into the editor without passing through a tRPC zod boundary.
- Testing: only the base
sanitizeHtml/sanitizeTextHtml functions are unit-tested (in @esposter/shared). Schema wiring needs no test — declaring the transform is the contract. marked.parse is third-party and untested.