From fdc40380bfa82adda78b2d19c654e9b521ea31e5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 23 Aug 2026 16:20:26 -0700 Subject: [PATCH] refactor(utils): add slugify and adopt it at the eight sites that hand-rolled it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same three-step derivation — lowercase, collapse each non-alphanumeric run to a hyphen, strip the leading and trailing one — sat in eight files. Two of them carried a TSDoc line whose only job was to warn that they mirrored a third (`instance-org.ts`: "Derives a slug the same way the admin organization API does"; `consolidate-users-into-organization.ts`: "Mirrors the slug derivation used by POST /api/v1/admin/organizations"). A comment asserting two implementations agree is the shape duplication takes when it cannot be checked. All eight were semantically identical. Two anchored the strip with `-+` rather than `-`, and one followed it with a `--+` collapse, but `[^a-z0-9]+` has already collapsed every run by that point, so neither could ever match more than the single-hyphen form. Nothing changes. Truncation stays at the call sites. Four of them bound the result — at 24, 64 and 80 — and only `copy-chats.ts` strips again afterwards, because slicing can land mid-run and leave a trailing hyphen the earlier strip never saw. Folding a `maxLength` into the helper would have had to pick one of those behaviors and silently impose it on the others. `artifact-stylesheet.ts` keeps its copy: it lives inside the `SIM_ARTIFACT_SHELL` template literal and runs in the viewer's browser, where there is no import to resolve. --- apps/sim/app/(landing)/models/utils.ts | 9 +----- .../app/api/v1/admin/organizations/route.ts | 8 ++--- .../[workspaceId]/skills/components/utils.ts | 8 ++--- .../workspace-forking/lib/copy/copy-chats.ts | 15 +++++----- .../sim/lib/billing/enterprise-owner-claim.ts | 8 ++--- .../lib/billing/enterprise-provisioning.ts | 8 ++--- apps/sim/lib/organizations/instance-org.ts | 11 ++----- .../consolidate-users-into-organization.ts | 14 ++------- packages/utils/src/string.test.ts | 30 +++++++++++++++++++ packages/utils/src/string.ts | 25 ++++++++++++++++ 10 files changed, 77 insertions(+), 59 deletions(-) diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts index d2ed4dcdf60..276d9519481 100644 --- a/apps/sim/app/(landing)/models/utils.ts +++ b/apps/sim/app/(landing)/models/utils.ts @@ -1,4 +1,5 @@ import type { ComponentType } from 'react' +import { slugify } from '@sim/utils/string' import { type ModelCapabilities, PROVIDER_DEFINITIONS } from '@/providers/models' const PROVIDER_PREFIXES: Record = { @@ -224,14 +225,6 @@ function trimTrailingZeros(value: string): string { return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1') } -function slugify(value: string): string { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .replace(/--+/g, '-') -} - function getProviderPrefixes(providerId: string): string[] { return PROVIDER_PREFIXES[providerId] ?? [`${providerId}/`] } diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index 26a2a652868..17987e13fb6 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -25,6 +25,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, dbReplica } from '@sim/db' import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { slugify } from '@sim/utils/string' import { count, eq } from 'drizzle-orm' import { adminV1CreateOrganizationContract, @@ -142,12 +143,7 @@ export const POST = withRouteHandler( ) } - const slug = - requestedSlug?.trim() || - name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') + const slug = requestedSlug?.trim() || slugify(name) const { organizationId, memberId } = await createOrganizationWithOwner({ ownerUserId: ownerId, diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts index 68216e2a579..0f3b144b37f 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts @@ -1,3 +1,4 @@ +import { slugify } from '@sim/utils/string' import { isApiClientError } from '@/lib/api/client/errors' export interface ParsedSkill { @@ -75,12 +76,7 @@ function inferNameFromHeading(markdown: string): string { const headingMatch = markdown.match(/^#{1,3}\s+(.+)$/m) if (!headingMatch) return '' - return headingMatch[1] - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 64) + return slugify(headingMatch[1]).slice(0, 64) } /** diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts index 312f765c641..fb716b91749 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { randomInt } from '@sim/utils/random' +import { slugify } from '@sim/utils/string' import { and, inArray, isNull } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' @@ -18,14 +19,14 @@ export interface ForkChatCopyPair { workflowName: string } -/** Lowercase a display name into the chat identifier charset (`[a-z0-9-]`), bounded. */ +/** + * Lowercase a display name into the chat identifier charset (`[a-z0-9-]`), bounded. + * + * The trailing strip runs again after the bound: truncation can land mid-run and + * leave a hyphen the pre-truncation strip never saw. + */ function slugifyForIdentifier(value: string): string { - const slug = value - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 24) - .replace(/-+$/g, '') + const slug = slugify(value).slice(0, 24).replace(/-+$/g, '') return slug || 'chat' } diff --git a/apps/sim/lib/billing/enterprise-owner-claim.ts b/apps/sim/lib/billing/enterprise-owner-claim.ts index c0c44cb17be..3634d71718a 100644 --- a/apps/sim/lib/billing/enterprise-owner-claim.ts +++ b/apps/sim/lib/billing/enterprise-owner-claim.ts @@ -4,7 +4,7 @@ import { member, outboxEvent, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { generateId } from '@sim/utils/id' -import { normalizeEmail } from '@sim/utils/string' +import { normalizeEmail, slugify } from '@sim/utils/string' import { and, count, desc, eq, or, sql } from 'drizzle-orm' import { z } from 'zod' import { getEmailSubject, renderEnterpriseOwnerInvitationEmail } from '@/components/emails' @@ -855,11 +855,7 @@ function sameWorkspaceSet(left: string[], right: string[]): boolean { } function claimOrganizationSlug(name: string, claimId: string): string { - const base = name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 80) + const base = slugify(name).slice(0, 80) return `${base || 'organization'}-${claimId.replace(/[^a-z0-9]/g, '')}` } diff --git a/apps/sim/lib/billing/enterprise-provisioning.ts b/apps/sim/lib/billing/enterprise-provisioning.ts index 08431d3a6a0..739ca721211 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.ts @@ -14,7 +14,7 @@ import { import { isOrgAdminRole, permissionSatisfies } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' -import { normalizeEmail } from '@sim/utils/string' +import { normalizeEmail, slugify } from '@sim/utils/string' import { and, count, @@ -998,11 +998,7 @@ export async function reviewEnterpriseProvisioning( } function slugifyOrganizationName(name: string, organizationId: string): string { - const base = name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 80) + const base = slugify(name).slice(0, 80) return `${base || 'organization'}-${organizationId.slice(-8)}` } diff --git a/apps/sim/lib/organizations/instance-org.ts b/apps/sim/lib/organizations/instance-org.ts index bbfddff234c..c9c309dc7d6 100644 --- a/apps/sim/lib/organizations/instance-org.ts +++ b/apps/sim/lib/organizations/instance-org.ts @@ -19,6 +19,7 @@ import { db } from '@sim/db' import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { slugify } from '@sim/utils/string' import { eq, sql } from 'drizzle-orm' import { createOrganizationWithOwnerTx, @@ -33,14 +34,6 @@ const logger = createLogger('InstanceOrganization') /** Bounds the wait for a concurrent provisioning attempt on another replica. */ const INSTANCE_ORG_LOCK_TIMEOUT_MS = 10_000 -/** Derives a slug the same way the admin organization API does. */ -function slugifyOrganizationName(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') -} - interface InstanceOrganizationConfig { name: string slug: string @@ -61,7 +54,7 @@ export function getInstanceOrganizationConfig(): InstanceOrganizationConfig | nu const name = env.INSTANCE_ORG_NAME?.trim() if (!name) return null - const slug = env.INSTANCE_ORG_SLUG?.trim() || slugifyOrganizationName(name) + const slug = env.INSTANCE_ORG_SLUG?.trim() || slugify(name) if (!slug) { logger.error('INSTANCE_ORG_NAME does not yield a usable slug; set INSTANCE_ORG_SLUG', { name }) return null diff --git a/apps/sim/scripts/consolidate-users-into-organization.ts b/apps/sim/scripts/consolidate-users-into-organization.ts index c70dd4209e8..f9cb4474547 100644 --- a/apps/sim/scripts/consolidate-users-into-organization.ts +++ b/apps/sim/scripts/consolidate-users-into-organization.ts @@ -65,7 +65,7 @@ import { db } from '@sim/db' import { member, organization, session, user, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { normalizeEmail } from '@sim/utils/string' +import { normalizeEmail, slugify } from '@sim/utils/string' import { and, count, eq, inArray, isNull, ne } from 'drizzle-orm' import { createOrganizationWithOwner, @@ -202,14 +202,6 @@ function parseArgs(argv: string[]): Options { return options } -/** Mirrors the slug derivation used by `POST /api/v1/admin/organizations`. */ -function slugifyOrganizationName(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') -} - async function findUserByEmail(email: string): Promise { const [row] = await db .select({ id: user.id, email: user.email, name: user.name }) @@ -244,7 +236,7 @@ async function resolveTargetOrganization(options: Options): Promise { + it('lowercases and hyphenates a display name', () => { + expect(slugify('Acme Corp')).toBe('acme-corp') + }) + + it('collapses each run of non-alphanumerics into a single hyphen', () => { + expect(slugify('Sim.ai <> RVTech')).toBe('sim-ai-rvtech') + }) + + it('drops leading and trailing separators', () => { + expect(slugify(' !!Hello World!! ')).toBe('hello-world') + }) + + it('returns an empty string when nothing survives', () => { + expect(slugify('***')).toBe('') + expect(slugify('')).toBe('') + }) + + /* ASCII-only: the class drops non-Latin text rather than transliterating it. */ + it('drops characters outside the ASCII alphanumerics', () => { + expect(slugify('Café')).toBe('caf') + expect(slugify('日本語')).toBe('') + }) + + it('preserves digits and hyphens already in the input', () => { + expect(slugify('workspace-2024')).toBe('workspace-2024') + }) +}) + describe('truncate', () => { it('appends the suffix when the string exceeds the slice length', () => { expect(truncate('hello world', 8)).toBe('hello wo...') diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 633b705be33..8d9a26b7a11 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -33,6 +33,31 @@ export function truncate(str: string, sliceLength: number, suffix = '...'): stri return str.length > sliceLength ? str.slice(0, sliceLength) + suffix : str } +/** + * Lowercases `value` into the `[a-z0-9-]` charset: every run of other characters + * becomes one hyphen, and leading and trailing hyphens are dropped. + * + * ASCII-only by design — the character class drops accented and non-Latin text + * rather than transliterating it, so `'Café'` yields `'caf'` and a wholly + * non-Latin name yields `''`. Callers that need a non-empty result supply their + * own fallback, because what to fall back to is theirs to decide. + * + * Truncation is likewise the caller's: slicing a slug can leave a trailing + * hyphen, and whether to strip it, and at what length, varies by the identifier + * being built. + * + * @example + * slugify('Acme Corp') // 'acme-corp' + * slugify(' !!Hello!! ') // 'hello' + * slugify('***') // '' + */ +export function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} + /** * Strips a trailing `_vN` version suffix from `value`, yielding the base type. * Only the single trailing suffix is removed; leading occurrences are left intact.