friendlyid

package module
v0.0.0-...-c539a45 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: BSD-3-Clause Imports: 5 Imported by: 0

README

go-ruby-friendly-id/friendly-id

friendly-id — go-ruby-friendly-id

Docs License Go

Pure-Go (CGO=0), MRI-faithful reimplementation of the Ruby friendly_id gem's slug engine — the computation behind pretty, human-readable URL slugs — to be bound into rbgo, the pure-Go embeddable Ruby interpreter, as a native require "friendly_id" module.

It reproduces the pieces of FriendlyId that are pure logic: slug normalization (the ActiveSupport parameterize semantics — accent transliteration, downcasing, non-alphanumeric collapsing), candidate generation with sequential collision-suffixing, an optional slug history, and the friendly finder that resolves a current slug — or an old historical slug — back to a record id, falling back to the numeric primary key.

What it is — and isn't. Everything host-specific — the base attribute to slugify, uniqueness lookups, and history persistence — is left as an injectable seam, so the engine runs with no database. A rbgo binding wires the seams to ActiveRecord; the reference MemStore backs tests and standalone use.

Features

Faithful port of the friendly_id slug core:

  • Parameterize(s, sep) — ActiveSupport-compatible slug generation: NFD accent transliteration (Åccéntsaccents, straßestrasse, œuvreoeuvre), downcase, collapse every run of non-[a-z0-9] to one separator, trim ends.
  • Config.Normalize / Candidates / Resolve — reserved-words rejection, MaxLength truncation, the slug_candidates sequence, and collision resolution by sequential suffixing (post, post-2, post-3, …) with a configurable sequence separator.
  • History (MemStore) — records each record's current slug plus every slug it ever held, so an old slug keeps resolving to the record (the :history module).
  • FinderModel.friendly.find(input): current slug → historical slug → numeric id, returning ErrNotFound when none match. Each lookup is a seam.

Usage

import "github.com/go-ruby-friendly-id/friendly-id"

cfg := friendlyid.Config{Reserved: []string{"new", "edit"}}
store := friendlyid.NewMemStore("Post")

// Generate a unique slug, resolving collisions sequentially.
slug, _ := cfg.Resolve("My First Post!", store.Exists) // "my-first-post"
_ = store.Assign("1", slug)

// A second post with the same title gets a suffixed slug.
slug2, _ := cfg.Resolve("My First Post!", store.Exists) // "my-first-post-2"
_ = store.Assign("2", slug2)

// The friendly finder resolves current + historical slugs, then the id.
id, _ := store.Finder(func(id string) bool { return id == "99" }).Find("my-first-post")
// id == "1"

Intended Ruby surface (via rbgo)

class Post < ApplicationRecord
  extend FriendlyId
  friendly_id :title, use: [:slugged, :history]
end

Post.friendly.find("my-first-post")   # resolves current or historical slug
post.slug                             # "my-first-post"
post.to_param                         # the slug, for URLs

Quality bar

100% test coverage (including every error branch); builds and tests green on all six 64-bit Go architectures (amd64, arm64, riscv64, loong64, ppc64le, s390x) plus js/wasm and wasip1/wasm; CGO=0; Go 1.26.4 floor.

License

BSD-3-Clause © the go-ruby-friendly-id/friendly-id authors.

Documentation

Overview

Package friendlyid is a pure-Go (CGO=0), MRI-faithful reimplementation of the Ruby friendly_id gem's slug engine: slug normalization (the ActiveSupport parameterize semantics), candidate generation with collision suffixing, an optional slug history, and the friendly finder that resolves a slug — or an old historical slug — back to a record id, falling back to a numeric id.

Everything host-specific (the base attribute to slugify, uniqueness lookups, and history persistence) is an injectable seam, so the engine runs with no database and a go-embedded-ruby (rbgo) binding can wire the seams to ActiveRecord.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrBlank is returned when a base string normalizes to an empty slug.
	ErrBlank = errors.New("friendlyid: blank slug")
	// ErrReserved is returned when a normalized slug is on the reserved-words list.
	ErrReserved = errors.New("friendlyid: reserved slug")
	// ErrNilExists is returned when Resolve is called without an existence check.
	ErrNilExists = errors.New("friendlyid: nil exists function")
	// ErrNotFound is the friendly finder's RecordNotFound analogue.
	ErrNotFound = errors.New("friendlyid: record not found")
	// ErrTaken is returned when assigning a slug already current for another record.
	ErrTaken = errors.New("friendlyid: slug already taken")
)

Functions

func Parameterize

func Parameterize(s, sep string) string

Parameterize turns an arbitrary string into a slug using the ActiveSupport parameterize semantics with sep as the separator: transliterate accented Latin characters to ASCII, lower-case, replace every run of characters outside [a-z0-9] with a single separator, and trim leading/trailing separators.

Types

type Config

type Config struct {
	// Separator replaces runs of non-alphanumeric characters in a slug.
	Separator string
	// SequenceSeparator joins a base slug and a numeric sequence when resolving a
	// collision (e.g. "post" -> "post-2"). Defaults to Separator when empty.
	SequenceSeparator string
	// Reserved is a case-insensitive blocklist of words that may never be used as
	// a slug (friendly_id's reserved_words), e.g. "new", "edit".
	Reserved []string
	// MaxLength truncates a normalized slug to at most this many bytes when > 0.
	MaxLength int
}

Config controls slug generation. The zero Config is usable: Separator defaults to "-" and SequenceSeparator to "-" when empty (see normalized()).

func (Config) Candidates

func (c Config) Candidates(base string, extra ...string) []string

Candidates returns the ordered slug candidates friendly_id tries for base plus any extra discriminators: the base slug first, then the base joined with each discriminator by the sequence separator. Empty/reserved candidates are skipped. The first returned candidate is always the normalized base (when valid).

func (Config) Normalize

func (c Config) Normalize(base string) (string, error)

Normalize produces the canonical friendly id for base: parameterized, reserved words rejected (returning ErrReserved), truncated to MaxLength, and rejected when empty (ErrBlank). It is the value friendly_id stores as the slug.

func (Config) Resolve

func (c Config) Resolve(base string, exists func(slug string) bool, extra ...string) (string, error)

Resolve returns the first candidate for which exists reports false — the slug that can be assigned without colliding. When every candidate collides it falls back to sequential suffixing of the base ("base-2", "base-3", …) until a free slug is found. base must normalize to a non-empty, non-reserved slug.

type Finder

type Finder struct {
	// BySlug resolves the model's current slug column. Required.
	BySlug func(slug string) (id string, found bool)
	// ByHistory resolves a slug recorded in the slug history. Nil disables history
	// (friendly_id without the :history module).
	ByHistory func(slug string) (id string, found bool)
	// ByID reports whether a record exists for a raw primary key. Nil disables the
	// numeric-id fallback.
	ByID func(id string) (found bool)
}

Finder implements friendly_id's Model.friendly.find(input): resolve a current slug to a record id, then (when history is enabled) an old historical slug, and finally fall back to treating the input as a raw primary key. Each lookup is an injectable seam so the finder runs with no database; an rbgo binding wires them to ActiveRecord.

func (Finder) Find

func (f Finder) Find(input string) (id string, err error)

Find resolves input to a record id, trying the current slug, then history, then the raw id, and returns ErrNotFound when none match. The BySlug seam is required; a nil Finder.BySlug makes Find always fall through to history/id.

type MemStore

type MemStore struct {
	// contains filtered or unexported fields
}

MemStore is an in-memory reference slug store for a single sluggable type. It tracks each record's current slug plus the full history of slugs it ever held, and exposes the seams a Config/Finder needs: Exists (uniqueness among current slugs), CurrentID (the model's slug column), and HistoryID (an old slug). An rbgo binding replaces it with an ActiveRecord-backed store; tests use it directly.

func NewMemStore

func NewMemStore(sluggableType string) *MemStore

NewMemStore returns an empty store for records of sluggableType.

func (*MemStore) Assign

func (m *MemStore) Assign(id, slug string) error

Assign records slug as id's current slug. The record's previous current slug (if any and different) is retained only in history, so an old slug keeps resolving to the record. A slug already current for a different record is rejected with ErrTaken.

func (*MemStore) CurrentID

func (m *MemStore) CurrentID(slug string) (string, bool)

CurrentID returns the record id whose current slug is slug (the BySlug seam).

func (*MemStore) Exists

func (m *MemStore) Exists(slug string) bool

Exists reports whether slug is currently assigned to some record — the uniqueness check Config.Resolve needs.

func (*MemStore) Finder

func (m *MemStore) Finder(byID func(id string) bool) Finder

Finder builds a Finder wired to this store, with the numeric-id fallback checking byID.

func (*MemStore) HistoryID

func (m *MemStore) HistoryID(slug string) (string, bool)

HistoryID returns the id of the most recent record to have held slug at any time (the ByHistory seam), even if the slug is no longer current.

func (*MemStore) Slugs

func (m *MemStore) Slugs() []Slug

Slugs returns a copy of the full recorded history in assignment order.

type Slug

type Slug struct {
	Slug          string
	SluggableType string
	SluggableID   string
}

Slug is one recorded slug row — the friendly_id_slugs table analogue used by the :history module.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL