@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
JavaScript
/**
* @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,
},
];
}
}