184 lines
5.3 KiB
TypeScript
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();
|
|
}
|