bizmatch-project/bizmatch-server/src/utils/slug.utils.ts

184 lines
5.3 KiB
TypeScript

/**
* Utility functions for generating and parsing SEO-friendly URL slugs
*
* Slug format: {title}-{location}-{short-id}
* Example: italian-restaurant-austin-tx-a3f7b2c1
*/
/**
* Generate a SEO-friendly URL slug from listing data
*
* @param title - The listing title (e.g., "Italian Restaurant")
* @param location - Location object with name, county, and state
* @param id - The listing UUID
* @returns SEO-friendly slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
*/
export function generateSlug(title: string, location: any, id: string): string {
if (!title || !id) {
throw new Error('Title and ID are required to generate a slug');
}
// Clean and slugify the title
const titleSlug = title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
.substring(0, 50); // Limit title to 50 characters
// Get location string
let locationSlug = '';
if (location) {
const locationName = location.name || location.county || '';
const state = location.state || '';
if (locationName) {
locationSlug = locationName
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
if (state) {
locationSlug = locationSlug
? `${locationSlug}-${state.toLowerCase()}`
: state.toLowerCase();
}
}
// Get first 8 characters of UUID for uniqueness
const shortId = id.substring(0, 8);
// Combine parts: title-location-id
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
const slug = parts.join('-');
// Final cleanup
return slug
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
.toLowerCase();
}
/**
* Extract the UUID from a slug
* The UUID is always the last segment (8 characters)
*
* @param slug - The URL slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
* @returns The short ID (e.g., "a3f7b2c1")
*/
export function extractShortIdFromSlug(slug: string): string {
if (!slug) {
throw new Error('Slug is required');
}
const parts = slug.split('-');
return parts[parts.length - 1];
}
/**
* Validate if a string looks like a valid slug
*
* @param slug - The string to validate
* @returns true if the string looks like a valid slug
*/
export function isValidSlug(slug: string): boolean {
if (!slug || typeof slug !== 'string') {
return false;
}
// Check if slug contains only lowercase letters, numbers, and hyphens
const slugPattern = /^[a-z0-9-]+$/;
if (!slugPattern.test(slug)) {
return false;
}
// Check if slug has a reasonable length (at least 10 chars for short-id + some content)
if (slug.length < 10) {
return false;
}
// Check if last segment looks like a UUID prefix (8 chars of alphanumeric)
const parts = slug.split('-');
const lastPart = parts[parts.length - 1];
return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart);
}
/**
* Check if a parameter is a slug (vs a UUID)
*
* @param param - The URL parameter
* @returns true if it's a slug, false if it's likely a UUID
*/
export function isSlug(param: string): boolean {
if (!param) {
return false;
}
// UUIDs have a specific format with hyphens at specific positions
// e.g., "a3f7b2c1-4d5e-6789-abcd-1234567890ef"
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
if (uuidPattern.test(param)) {
return false; // It's a UUID
}
// If it contains at least 3 parts (e.g., title-state-id or title-city-state-id) and looks like our slug format, it's probably a slug
return param.split('-').length >= 3 && isValidSlug(param);
}
/**
* Regenerate slug from updated listing data
* Useful when title or location changes
*
* @param title - Updated title
* @param location - Updated location
* @param existingSlug - The current slug (to preserve short-id)
* @returns New slug with same short-id
*/
export function regenerateSlug(title: string, location: any, existingSlug: string): string {
if (!existingSlug) {
throw new Error('Existing slug is required to regenerate');
}
const shortId = extractShortIdFromSlug(existingSlug);
// Reconstruct full UUID from short-id (not possible, so we use full existing slug's ID)
// In practice, you'd need the full UUID from the database
// For now, we'll construct a new slug with the short-id
const titleSlug = title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.substring(0, 50);
let locationSlug = '';
if (location) {
const locationName = location.name || location.county || '';
const state = location.state || '';
if (locationName) {
locationSlug = locationName
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
if (state) {
locationSlug = locationSlug
? `${locationSlug}-${state.toLowerCase()}`
: state.toLowerCase();
}
}
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
return parts.join('-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
}