Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions apps/sim/app/(landing)/models/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {
Expand Down Expand Up @@ -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}/`]
}
Expand Down
8 changes: 2 additions & 6 deletions apps/sim/app/api/v1/admin/organizations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { slugify } from '@sim/utils/string'
import { isApiClientError } from '@/lib/api/client/errors'

export interface ParsedSkill {
Expand Down Expand Up @@ -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)
}

/**
Expand Down
15 changes: 8 additions & 7 deletions apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'
}

Expand Down
8 changes: 2 additions & 6 deletions apps/sim/lib/billing/enterprise-owner-claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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, '')}`
}

Expand Down
8 changes: 2 additions & 6 deletions apps/sim/lib/billing/enterprise-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}`
}

Expand Down
11 changes: 2 additions & 9 deletions apps/sim/lib/organizations/instance-org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
14 changes: 3 additions & 11 deletions apps/sim/scripts/consolidate-users-into-organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<UserRow | null> {
const [row] = await db
.select({ id: user.id, email: user.email, name: user.name })
Expand Down Expand Up @@ -244,7 +236,7 @@ async function resolveTargetOrganization(options: Options): Promise<TargetOrgani
? eq(organization.id, options.orgId)
: options.orgSlug
? eq(organization.slug, options.orgSlug)
: eq(organization.slug, slugifyOrganizationName(options.orgName as string))
: eq(organization.slug, slugify(options.orgName as string))

const [existing] = await db
.select({ id: organization.id, name: organization.name, slug: organization.slug })
Expand Down Expand Up @@ -302,7 +294,7 @@ async function resolveTargetOrganization(options: Options): Promise<TargetOrgani
return {
id: null,
name: options.orgName,
slug: options.orgSlug?.trim() || slugifyOrganizationName(options.orgName),
slug: options.orgSlug?.trim() || slugify(options.orgName),
ownerUserId: owner.id,
ownerEmail: owner.email,
mustBeCreated: true,
Expand Down
30 changes: 30 additions & 0 deletions packages/utils/src/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,40 @@ import {
projectEscapedMarkdownForSearch,
sanitizeForJsonb,
sanitizeValueForJsonb,
slugify,
stripVersionSuffix,
truncate,
} from './string.js'

describe('slugify', () => {
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...')
Expand Down
25 changes: 25 additions & 0 deletions packages/utils/src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading