Overview

Generate URL-safe slugs from text with special character handling.

Features

  • Transliteration
  • Stop word removal
  • Unique slugs
  • Validation

Installation

npx hanma add elysia-slug --framework elysia

Code

/**
 * Character replacements for transliteration
 */
const TRANSLITERATION_MAP: Record<string, string> = {
  // Latin extended
  ae: "ae", oe: "oe", ue: "ue",
  // German
  "\u00e4": "ae", "\u00f6": "oe", "\u00fc": "ue", "\u00df": "ss",
  // French
  "\u00e0": "a", "\u00e2": "a", "\u00e7": "c", "\u00e8": "e", "\u00e9": "e",
  "\u00ea": "e", "\u00eb": "e", "\u00ee": "i", "\u00ef": "i", "\u00f4": "o",
  "\u00f9": "u", "\u00fb": "u", "\u00ff": "y",
  // Spanish
  "\u00f1": "n", "\u00e1": "a", "\u00ed": "i", "\u00f3": "o", "\u00fa": "u",
  // Polish
  "\u0142": "l", "\u0144": "n", "\u015b": "s", "\u017a": "z", "\u017c": "z",
  // Czech
  "\u0159": "r", "\u010d": "c", "\u010f": "d", "\u0148": "n", "\u0165": "t",
  // Nordic
  "\u00e5": "a", "\u00f8": "o", "\u00e6": "ae",
};

export interface SlugOptions {
  /** Maximum length of slug (default: 100) */
  maxLength?: number;
  /** Separator character (default: "-") */
  separator?: string;
  /** Convert to lowercase (default: true) */
  lowercase?: boolean;
  /** Remove stop words (default: false) */
  removeStopWords?: boolean;
  /** Custom stop words to remove */
  stopWords?: string[];
}

const DEFAULT_STOP_WORDS = [
  "a", "an", "and", "are", "as", "at", "be", "by", "for",
  "from", "has", "he", "in", "is", "it", "its", "of", "on",
  "that", "the", "to", "was", "were", "will", "with",
];

/**
 * Transliterate special characters
 */
const transliterate = (text: string): string => {
  let result = text;

  for (const [char, replacement] of Object.entries(TRANSLITERATION_MAP)) {
    result = result.replace(new RegExp(char, "gi"), replacement);
  }

  // Remove remaining diacritics using Unicode normalization
  result = result.normalize("NFD").replace(/[\u0300-\u036f]/g, "");

  return result;
};

/**
 * Generate a URL-safe slug from text
 */
export const slugify = (text: string, options: SlugOptions = {}): string => {
  const config = {
    maxLength: options.maxLength ?? 100,
    separator: options.separator ?? "-",
    lowercase: options.lowercase ?? true,
    removeStopWords: options.removeStopWords ?? false,
    stopWords: options.stopWords ?? DEFAULT_STOP_WORDS,
  };

  let slug = text;

  // Transliterate special characters
  slug = transliterate(slug);

  // Convert to lowercase if needed
  if (config.lowercase) {
    slug = slug.toLowerCase();
  }

  // Remove stop words if enabled
  if (config.removeStopWords) {
    const stopWordsPattern = new RegExp(
      `\\b(${config.stopWords.join("|")})\\b`,
      "gi"
    );
    slug = slug.replace(stopWordsPattern, "");
  }

  // Replace non-alphanumeric characters with separator
  slug = slug
    .replace(/[^a-zA-Z0-9]+/g, config.separator)
    .replace(new RegExp(`${config.separator}+`, "g"), config.separator)
    .replace(new RegExp(`^${config.separator}|${config.separator}$`, "g"), "");

  // Truncate to max length (at word boundary if possible)
  if (slug.length > config.maxLength) {
    slug = slug.substring(0, config.maxLength);
    const lastSeparator = slug.lastIndexOf(config.separator);
    if (lastSeparator > config.maxLength * 0.7) {
      slug = slug.substring(0, lastSeparator);
    }
  }

  return slug;
};

/**
 * Generate a unique slug by appending a short ID
 */
export const uniqueSlug = (
  text: string,
  options: SlugOptions = {}
): string => {
  const baseSlug = slugify(text, options);
  const shortId = Math.random().toString(36).slice(2, 8);

  const maxBaseLength = (options.maxLength ?? 100) - shortId.length - 1;
  const truncatedBase = baseSlug.slice(0, maxBaseLength);

  return `${truncatedBase}-${shortId}`;
};

/**
 * Validate if a string is a valid slug
 */
export const isValidSlug = (slug: string, separator = "-"): boolean => {
  if (!slug || slug.length === 0) return false;

  const pattern = new RegExp(`^[a-z0-9]+(?:${separator}[a-z0-9]+)*$`);
  return pattern.test(slug);
};

/**
 * Parse a slug back to readable text
 */
export const deslugify = (slug: string, separator = "-"): string => {
  return slug
    .split(separator)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(" ");
};

/* USAGE:

import { slugify, uniqueSlug, isValidSlug, deslugify } from "./utils/slug";

// Basic usage
slugify("Hello World!"); // "hello-world"
slugify("Cafe au lait"); // "cafe-au-lait"

// With options
slugify("Hello World", { uppercase: false, separator: "_" }); // "hello_world"
slugify("The Quick Brown Fox", { removeStopWords: true }); // "quick-brown-fox"

// Unique slugs for database entries
uniqueSlug("My Blog Post"); // "my-blog-post-x7k2m9"

// Validation
isValidSlug("hello-world"); // true
isValidSlug("Hello World"); // false

// Reverse
deslugify("hello-world"); // "Hello World"

// Example with Elysia
import { Elysia, t } from "elysia";

const app = new Elysia()
  .post("/posts", async ({ body }) => {
    const slug = uniqueSlug(body.title, { maxLength: 80 });
    // Save post with slug...
    return { slug };
  }, {
    body: t.Object({
      title: t.String(),
      content: t.String(),
    }),
  });

*/

Usage

import { slugify, uniqueSlug } from './utils/slug';

slugify('Hello World!'); // 'hello-world'
uniqueSlug('My Blog Post'); // 'my-blog-post-x7k2m9'

Dependencies

No external dependencies required.

Frequently Asked Questions

How do I install Slug Generator?
Run the following command: npx hanma add elysia-slug --framework elysia
How do I use this snippet?
Copy the code and paste it into your project. The snippet will be saved to src/utils/slug.ts.
Can I customize this snippet?
Yes! All snippets are copy-paste code that you own. Feel free to modify them to fit your needs.
Does this work with other frameworks?
This snippet is designed for the specific framework. Check the other framework pages for equivalent implementations.