UNPKG

@ai-growth/nextjs

Version:

Seamlessly integrate Sanity CMS with Next.js applications for automated blog routing and rendering

235 lines (234 loc) 6.78 kB
/** * @fileoverview URL Utilities for SEO * * This module provides functions for building and processing URLs * for SEO purposes, including canonical URLs and URL normalization. */ /** * Build a complete URL from components * * @param options - URL building options * @returns Complete URL string */ export function buildURL(options) { const { baseUrl, path, query, fragment, trailingSlash = false } = options; let url = baseUrl; // Ensure base URL doesn't end with slash unless it's root if (url.endsWith('/') && url !== baseUrl.split('/').slice(0, 3).join('/') + '/') { url = url.slice(0, -1); } // Add path if (path) { const cleanPath = path.startsWith('/') ? path : `/${path}`; url += cleanPath; } // Add trailing slash if requested and not already present if (trailingSlash && !url.endsWith('/') && !path?.includes('.')) { url += '/'; } // Add query parameters if (query && Object.keys(query).length > 0) { const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== '') { params.append(key, value); } }); const queryString = params.toString(); if (queryString) { url += `?${queryString}`; } } // Add fragment if (fragment) { url += `#${fragment}`; } return url; } /** * Normalize a URL for canonical purposes * * @param url - URL to normalize * @returns Normalized URL */ export function normalizeURL(url) { try { const urlObj = new URL(url); // Convert to lowercase (except path) urlObj.protocol = urlObj.protocol.toLowerCase(); urlObj.hostname = urlObj.hostname.toLowerCase(); // Remove default ports if ((urlObj.protocol === 'http:' && urlObj.port === '80') || (urlObj.protocol === 'https:' && urlObj.port === '443')) { urlObj.port = ''; } // Remove trailing slash from pathname (except root) if (urlObj.pathname.length > 1 && urlObj.pathname.endsWith('/')) { urlObj.pathname = urlObj.pathname.slice(0, -1); } // Sort query parameters for consistency const params = new URLSearchParams(urlObj.search); const sortedParams = new URLSearchParams(); Array.from(params.keys()) .sort() .forEach(key => { const values = params.getAll(key); values.forEach(value => sortedParams.append(key, value)); }); urlObj.search = sortedParams.toString(); // Remove fragment for canonical URLs urlObj.hash = ''; return urlObj.toString(); } catch { return url; // Return original if invalid } } /** * Extract domain from URL * * @param url - URL to extract domain from * @returns Domain string */ export function extractDomain(url) { try { const urlObj = new URL(url); return urlObj.hostname; } catch { return ''; } } /** * Check if URL is absolute * * @param url - URL to check * @returns Whether URL is absolute */ export function isAbsoluteURL(url) { try { new URL(url); return true; } catch { return false; } } /** * Convert relative URL to absolute * * @param relativeUrl - Relative URL * @param baseUrl - Base URL for resolution * @returns Absolute URL */ export function makeAbsoluteURL(relativeUrl, baseUrl) { if (isAbsoluteURL(relativeUrl)) { return relativeUrl; } try { return new URL(relativeUrl, baseUrl).toString(); } catch { return baseUrl + (relativeUrl.startsWith('/') ? relativeUrl : `/${relativeUrl}`); } } /** * Generate SEO-friendly URL slug from text * * @param text - Text to convert to slug * @param maxLength - Maximum length of slug * @returns URL slug */ export function createSlug(text, maxLength = 50) { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') // Remove special characters .replace(/\s+/g, '-') // Replace spaces with hyphens .replace(/-+/g, '-') // Replace multiple hyphens with single .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens .substring(0, maxLength) .replace(/-+$/, ''); // Remove trailing hyphens after truncation } /** * Validate URL format * * @param url - URL to validate * @returns Validation result */ export function validateURL(url) { const errors = []; const warnings = []; try { const urlObj = new URL(url); // Check protocol if (!['http:', 'https:'].includes(urlObj.protocol)) { errors.push('URL must use HTTP or HTTPS protocol'); } // Check hostname if (!urlObj.hostname) { errors.push('URL must have a valid hostname'); } // Check for common issues if (urlObj.pathname.includes('//')) { warnings.push('URL contains double slashes in path'); } if (urlObj.pathname.includes(' ')) { warnings.push('URL contains spaces in path'); } if (urlObj.search.includes(' ')) { warnings.push('URL contains spaces in query parameters'); } // Check length (URLs over 2048 chars may have issues) if (url.length > 2048) { warnings.push('URL is very long and may cause issues'); } } catch (error) { errors.push('Invalid URL format'); } return { isValid: errors.length === 0, errors, warnings, }; } /** * Build breadcrumb URLs from path * * @param currentUrl - Current page URL * @param baseUrl - Base site URL * @returns Array of breadcrumb URLs with labels */ export function buildBreadcrumbUrls(currentUrl, baseUrl) { try { const urlObj = new URL(currentUrl); const pathSegments = urlObj.pathname.split('/').filter(segment => segment); const breadcrumbs = [ { url: baseUrl, label: 'Home', position: 1, }, ]; let currentPath = ''; pathSegments.forEach((segment, index) => { currentPath += `/${segment}`; breadcrumbs.push({ url: `${baseUrl}${currentPath}`, label: segment.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), position: index + 2, }); }); return breadcrumbs; } catch { return [ { url: baseUrl, label: 'Home', position: 1, }, ]; } }