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.