Skip to content

Commit cc08749

Browse files
authored
refactor(utils): add slugify and adopt it at the eight sites that hand-rolled it (#7018)
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.
1 parent 49593b3 commit cc08749

10 files changed

Lines changed: 77 additions & 59 deletions

File tree

apps/sim/app/(landing)/models/utils.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ComponentType } from 'react'
2+
import { slugify } from '@sim/utils/string'
23
import { type ModelCapabilities, PROVIDER_DEFINITIONS } from '@/providers/models'
34

45
const PROVIDER_PREFIXES: Record<string, string[]> = {
@@ -224,14 +225,6 @@ function trimTrailingZeros(value: string): string {
224225
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
225226
}
226227

227-
function slugify(value: string): string {
228-
return value
229-
.toLowerCase()
230-
.replace(/[^a-z0-9]+/g, '-')
231-
.replace(/^-+|-+$/g, '')
232-
.replace(/--+/g, '-')
233-
}
234-
235228
function getProviderPrefixes(providerId: string): string[] {
236229
return PROVIDER_PREFIXES[providerId] ?? [`${providerId}/`]
237230
}

apps/sim/app/api/v1/admin/organizations/route.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2525
import { db, dbReplica } from '@sim/db'
2626
import { member, organization, user } from '@sim/db/schema'
2727
import { createLogger } from '@sim/logger'
28+
import { slugify } from '@sim/utils/string'
2829
import { count, eq } from 'drizzle-orm'
2930
import {
3031
adminV1CreateOrganizationContract,
@@ -142,12 +143,7 @@ export const POST = withRouteHandler(
142143
)
143144
}
144145

145-
const slug =
146-
requestedSlug?.trim() ||
147-
name
148-
.toLowerCase()
149-
.replace(/[^a-z0-9]+/g, '-')
150-
.replace(/^-|-$/g, '')
146+
const slug = requestedSlug?.trim() || slugify(name)
151147

152148
const { organizationId, memberId } = await createOrganizationWithOwner({
153149
ownerUserId: ownerId,

apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { slugify } from '@sim/utils/string'
12
import { isApiClientError } from '@/lib/api/client/errors'
23

34
export interface ParsedSkill {
@@ -75,12 +76,7 @@ function inferNameFromHeading(markdown: string): string {
7576
const headingMatch = markdown.match(/^#{1,3}\s+(.+)$/m)
7677
if (!headingMatch) return ''
7778

78-
return headingMatch[1]
79-
.trim()
80-
.toLowerCase()
81-
.replace(/[^a-z0-9]+/g, '-')
82-
.replace(/^-|-$/g, '')
83-
.slice(0, 64)
79+
return slugify(headingMatch[1]).slice(0, 64)
8480
}
8581

8682
/**

apps/sim/ee/workspace-forking/lib/copy/copy-chats.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { generateId, generateShortId } from '@sim/utils/id'
44
import { isRecordLike } from '@sim/utils/object'
55
import { randomInt } from '@sim/utils/random'
6+
import { slugify } from '@sim/utils/string'
67
import { and, inArray, isNull } from 'drizzle-orm'
78
import type { DbOrTx } from '@/lib/db/types'
89

@@ -18,14 +19,14 @@ export interface ForkChatCopyPair {
1819
workflowName: string
1920
}
2021

21-
/** Lowercase a display name into the chat identifier charset (`[a-z0-9-]`), bounded. */
22+
/**
23+
* Lowercase a display name into the chat identifier charset (`[a-z0-9-]`), bounded.
24+
*
25+
* The trailing strip runs again after the bound: truncation can land mid-run and
26+
* leave a hyphen the pre-truncation strip never saw.
27+
*/
2228
function slugifyForIdentifier(value: string): string {
23-
const slug = value
24-
.toLowerCase()
25-
.replace(/[^a-z0-9]+/g, '-')
26-
.replace(/^-+|-+$/g, '')
27-
.slice(0, 24)
28-
.replace(/-+$/g, '')
29+
const slug = slugify(value).slice(0, 24).replace(/-+$/g, '')
2930
return slug || 'chat'
3031
}
3132

apps/sim/lib/billing/enterprise-owner-claim.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { member, outboxEvent, user, workspace } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { safeCompare } from '@sim/security/compare'
66
import { generateId } from '@sim/utils/id'
7-
import { normalizeEmail } from '@sim/utils/string'
7+
import { normalizeEmail, slugify } from '@sim/utils/string'
88
import { and, count, desc, eq, or, sql } from 'drizzle-orm'
99
import { z } from 'zod'
1010
import { getEmailSubject, renderEnterpriseOwnerInvitationEmail } from '@/components/emails'
@@ -855,11 +855,7 @@ function sameWorkspaceSet(left: string[], right: string[]): boolean {
855855
}
856856

857857
function claimOrganizationSlug(name: string, claimId: string): string {
858-
const base = name
859-
.toLowerCase()
860-
.replace(/[^a-z0-9]+/g, '-')
861-
.replace(/^-|-$/g, '')
862-
.slice(0, 80)
858+
const base = slugify(name).slice(0, 80)
863859
return `${base || 'organization'}-${claimId.replace(/[^a-z0-9]/g, '')}`
864860
}
865861

apps/sim/lib/billing/enterprise-provisioning.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { isOrgAdminRole, permissionSatisfies } from '@sim/platform-authz/workspace'
1515
import { generateId } from '@sim/utils/id'
1616
import { isRecordLike } from '@sim/utils/object'
17-
import { normalizeEmail } from '@sim/utils/string'
17+
import { normalizeEmail, slugify } from '@sim/utils/string'
1818
import {
1919
and,
2020
count,
@@ -998,11 +998,7 @@ export async function reviewEnterpriseProvisioning(
998998
}
999999

10001000
function slugifyOrganizationName(name: string, organizationId: string): string {
1001-
const base = name
1002-
.toLowerCase()
1003-
.replace(/[^a-z0-9]+/g, '-')
1004-
.replace(/^-|-$/g, '')
1005-
.slice(0, 80)
1001+
const base = slugify(name).slice(0, 80)
10061002
return `${base || 'organization'}-${organizationId.slice(-8)}`
10071003
}
10081004

apps/sim/lib/organizations/instance-org.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { db } from '@sim/db'
1919
import { member, organization, user } from '@sim/db/schema'
2020
import { createLogger } from '@sim/logger'
2121
import { getErrorMessage } from '@sim/utils/errors'
22+
import { slugify } from '@sim/utils/string'
2223
import { eq, sql } from 'drizzle-orm'
2324
import {
2425
createOrganizationWithOwnerTx,
@@ -33,14 +34,6 @@ const logger = createLogger('InstanceOrganization')
3334
/** Bounds the wait for a concurrent provisioning attempt on another replica. */
3435
const INSTANCE_ORG_LOCK_TIMEOUT_MS = 10_000
3536

36-
/** Derives a slug the same way the admin organization API does. */
37-
function slugifyOrganizationName(name: string): string {
38-
return name
39-
.toLowerCase()
40-
.replace(/[^a-z0-9]+/g, '-')
41-
.replace(/^-|-$/g, '')
42-
}
43-
4437
interface InstanceOrganizationConfig {
4538
name: string
4639
slug: string
@@ -61,7 +54,7 @@ export function getInstanceOrganizationConfig(): InstanceOrganizationConfig | nu
6154
const name = env.INSTANCE_ORG_NAME?.trim()
6255
if (!name) return null
6356

64-
const slug = env.INSTANCE_ORG_SLUG?.trim() || slugifyOrganizationName(name)
57+
const slug = env.INSTANCE_ORG_SLUG?.trim() || slugify(name)
6558
if (!slug) {
6659
logger.error('INSTANCE_ORG_NAME does not yield a usable slug; set INSTANCE_ORG_SLUG', { name })
6760
return null

apps/sim/scripts/consolidate-users-into-organization.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import { db } from '@sim/db'
6565
import { member, organization, session, user, workspace } from '@sim/db/schema'
6666
import { createLogger } from '@sim/logger'
6767
import { getErrorMessage } from '@sim/utils/errors'
68-
import { normalizeEmail } from '@sim/utils/string'
68+
import { normalizeEmail, slugify } from '@sim/utils/string'
6969
import { and, count, eq, inArray, isNull, ne } from 'drizzle-orm'
7070
import {
7171
createOrganizationWithOwner,
@@ -202,14 +202,6 @@ function parseArgs(argv: string[]): Options {
202202
return options
203203
}
204204

205-
/** Mirrors the slug derivation used by `POST /api/v1/admin/organizations`. */
206-
function slugifyOrganizationName(name: string): string {
207-
return name
208-
.toLowerCase()
209-
.replace(/[^a-z0-9]+/g, '-')
210-
.replace(/^-|-$/g, '')
211-
}
212-
213205
async function findUserByEmail(email: string): Promise<UserRow | null> {
214206
const [row] = await db
215207
.select({ id: user.id, email: user.email, name: user.name })
@@ -244,7 +236,7 @@ async function resolveTargetOrganization(options: Options): Promise<TargetOrgani
244236
? eq(organization.id, options.orgId)
245237
: options.orgSlug
246238
? eq(organization.slug, options.orgSlug)
247-
: eq(organization.slug, slugifyOrganizationName(options.orgName as string))
239+
: eq(organization.slug, slugify(options.orgName as string))
248240

249241
const [existing] = await db
250242
.select({ id: organization.id, name: organization.name, slug: organization.slug })
@@ -302,7 +294,7 @@ async function resolveTargetOrganization(options: Options): Promise<TargetOrgani
302294
return {
303295
id: null,
304296
name: options.orgName,
305-
slug: options.orgSlug?.trim() || slugifyOrganizationName(options.orgName),
297+
slug: options.orgSlug?.trim() || slugify(options.orgName),
306298
ownerUserId: owner.id,
307299
ownerEmail: owner.email,
308300
mustBeCreated: true,

packages/utils/src/string.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,40 @@ import {
99
projectEscapedMarkdownForSearch,
1010
sanitizeForJsonb,
1111
sanitizeValueForJsonb,
12+
slugify,
1213
stripVersionSuffix,
1314
truncate,
1415
} from './string.js'
1516

17+
describe('slugify', () => {
18+
it('lowercases and hyphenates a display name', () => {
19+
expect(slugify('Acme Corp')).toBe('acme-corp')
20+
})
21+
22+
it('collapses each run of non-alphanumerics into a single hyphen', () => {
23+
expect(slugify('Sim.ai <> RVTech')).toBe('sim-ai-rvtech')
24+
})
25+
26+
it('drops leading and trailing separators', () => {
27+
expect(slugify(' !!Hello World!! ')).toBe('hello-world')
28+
})
29+
30+
it('returns an empty string when nothing survives', () => {
31+
expect(slugify('***')).toBe('')
32+
expect(slugify('')).toBe('')
33+
})
34+
35+
/* ASCII-only: the class drops non-Latin text rather than transliterating it. */
36+
it('drops characters outside the ASCII alphanumerics', () => {
37+
expect(slugify('Café')).toBe('caf')
38+
expect(slugify('日本語')).toBe('')
39+
})
40+
41+
it('preserves digits and hyphens already in the input', () => {
42+
expect(slugify('workspace-2024')).toBe('workspace-2024')
43+
})
44+
})
45+
1646
describe('truncate', () => {
1747
it('appends the suffix when the string exceeds the slice length', () => {
1848
expect(truncate('hello world', 8)).toBe('hello wo...')

packages/utils/src/string.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,31 @@ export function truncate(str: string, sliceLength: number, suffix = '...'): stri
3333
return str.length > sliceLength ? str.slice(0, sliceLength) + suffix : str
3434
}
3535

36+
/**
37+
* Lowercases `value` into the `[a-z0-9-]` charset: every run of other characters
38+
* becomes one hyphen, and leading and trailing hyphens are dropped.
39+
*
40+
* ASCII-only by design — the character class drops accented and non-Latin text
41+
* rather than transliterating it, so `'Café'` yields `'caf'` and a wholly
42+
* non-Latin name yields `''`. Callers that need a non-empty result supply their
43+
* own fallback, because what to fall back to is theirs to decide.
44+
*
45+
* Truncation is likewise the caller's: slicing a slug can leave a trailing
46+
* hyphen, and whether to strip it, and at what length, varies by the identifier
47+
* being built.
48+
*
49+
* @example
50+
* slugify('Acme Corp') // 'acme-corp'
51+
* slugify(' !!Hello!! ') // 'hello'
52+
* slugify('***') // ''
53+
*/
54+
export function slugify(value: string): string {
55+
return value
56+
.toLowerCase()
57+
.replace(/[^a-z0-9]+/g, '-')
58+
.replace(/^-|-$/g, '')
59+
}
60+
3661
/**
3762
* Strips a trailing `_vN` version suffix from `value`, yielding the base type.
3863
* Only the single trailing suffix is removed; leading occurrences are left intact.

0 commit comments

Comments
 (0)