Skip to content

Move slug generation onto locale_slug - #693

Merged
ddon merged 2 commits into
BeamLabEU:mainfrom
mdon:main
Aug 9, 2026
Merged

Move slug generation onto locale_slug#693
ddon merged 2 commits into
BeamLabEU:mainfrom
mdon:main

Conversation

@mdon

@mdon mdon commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

PhoenixKit.Utils.Slug now delegates to the
locale_slug package. It keeps its exact public
shape — slugify/2, transliterate/1, ensure_unique/2 — so no caller changes.

Two live bugs go with it:

"Größe Fußball"  -> gro-e-fu-ball    NFD strips the umlaut; ß has no canonical
                                     decomposition, so the ASCII pass deleted it
"Цветокоррекция" -> ""               wherever a caller omitted transliterate: true
                                     (5 sites in entities, 2 in ai)

The empty-slug case was the worse one: callers read empty as "no slug yet" and regenerate
forever, which is how a CSV re-import of a Cyrillic catalogue inserted the whole feed
again on every run.

Neither was fixable in the old table, because one table cannot be both German
(ö → oe) and Estonian (ö → o)
. That is why this moved to a package that takes a
locale. No other Elixir slug package does — slugger applies German rules to everything,
slugify the reverse, and the maintained transliterators expose arity-1 functions that
structurally cannot take one.

Behaviour changes

  • Transliteration is now the default. :transliterate is accepted and ignored so the
    existing call sites keep compiling. This is what closes the empty-slug bug.
  • Stored slugs are not rewritten. Only newly generated ones change, so live URLs are
    unaffected.
  • transliterate/1 keeps its exact old contract: unmapped characters pass through.
    generate_username_from_email/1 depends on it — it does String.replace(".", "_")
    afterwards, so folding punctuation into a separator would turn ülo.kask@ into
    ulokask instead of ulo_kask.

The package

Pure Elixir, zero dependencies. Verified against the published artifact rather than a
path dep: a clean project resolves it with no transitive deps and compiles without
yamerl present. Romanization comes from cited standards (BGN/PCGN for Cyrillic, ISO 843
Type 2 for Greek), including their contextual rules — Greek μπ is b at a word edge and
mp inside one; Russian е is ye word-initially and after a vowel or sign.

Note on one test

A test previously asserted "without transliteration a Cyrillic title still collapses to
empty"
. That was the bug, not the contract; it now asserts the fix.

Verification

38 doctests, 3189 tests, 0 failures against the published locale_slug 0.1.0.

Rebased onto current main after #692 merged.

Downstream

Seven module PRs depend on the release that carries this: ecommerce#19, posts#15,
newsletters#31, projects#36, publishing#39, dashboards#5, document_creator#31.

mdon added 2 commits August 9, 2026 22:18
PhoenixKit.Utils.Slug keeps its public shape — slugify/2, transliterate/1,
ensure_unique/2 — and delegates the hard part. Two live bugs go with it:

  "Größe Fußball" -> gro-e-fu-ball    (NFD strips the umlaut; ß has no
                                       canonical decomposition, so the ASCII
                                       pass deleted it)
  "Цветокоррекция" -> ""              (wherever a caller omitted
                                       transliterate: true — 5 sites in
                                       entities, 2 in ai)

The empty-slug case was the worse one: callers read empty as "no slug yet"
and regenerate forever, which is how a CSV re-import of a Cyrillic catalogue
inserted the whole feed again on every run.

Neither was fixable in the old table, because one table cannot be both German
(ö -> oe) and Estonian (ö -> o). That is why this moved to a package that
takes a locale.

Transliteration is now the DEFAULT. :transliterate is accepted and ignored so
existing call sites keep compiling. Stored slugs are not rewritten — only
newly generated ones change, so live URLs are unaffected.

transliterate/1 keeps its exact old contract: unmapped characters pass
through. generate_username_from_email/1 depends on it — it does
String.replace(".", "_") afterwards, so folding punctuation into a separator
would turn ülo.kask@ into ulokask instead of ulo_kask.

One test previously asserted "without transliteration a Cyrillic title still
collapses to empty". That was the bug, not the contract; it now asserts the
fix.

NOT MERGEABLE until locale_slug is published to Hex — the dep pins ~> 0.1 and
nothing satisfies it yet. Verified locally with:

  LOCALE_SLUG_PATH=../../../Elixir/locale_slug mix test    # 3189 tests, 0 failures
The package is published, so the dep now resolves from Hex rather than a local
path and this branch is mergeable.

Verified against the PUBLISHED artifact, not the path dep — a path dep can hide
packaging faults (a file missing from the `files:` list, a dev-only dep that is
actually needed at compile time). Checks:

  - clean project, `{:locale_slug, "~> 0.1"}`: resolves, compiles, and pulls in
    ZERO transitive deps
  - compiles with yamerl absent, confirming the apply/3 indirection in the
    generator task keeps it dev-only for consumers
  - 11/11 slug behaviours correct from the Hex build
  - phoenix_kit: 38 doctests, 3189 tests, 0 failures
mdon added a commit to mdon/phoenix_kit_entities that referenced this pull request Aug 9, 2026
data_form.ex had the language available at every slug site and was discarding it:

  compute_slug_and_data(socket, title, true, current_lang, changeset, data)
    slug_text = title |> Slug.slugify()      # current_lang is a parameter

The secondary-language path had `current_lang` bound as a parameter; the primary
path had `socket.assigns[:primary_language]` sitting there unused. So a German
entry slugged with the neutral rule ("grosse") where German orthography wants
"groesse", and a Ukrainian one romanized Russian-style.

auto_generate_entity_slug/3 becomes /4 with a default, so the two param-level
call sites can pass the primary language.

NOT changed, deliberately:
  - mirror/importer.ex — imports carry no language, so neutral is correct
  - entity_form.ex — slugs a machine-facing key with separator: "_"

The empty-slug bug at these sites is already fixed by core defaulting
transliteration on; this is the locale-tuning half.

Requires the phoenix_kit release carrying the locale-aware Slug
(BeamLabEU/phoenix_kit#693). Against local core: 1032 tests, 21 failures — all
21 PRE-EXISTING (identical count with this file reverted).
@ddon
ddon merged commit 2562c3c into BeamLabEU:main Aug 9, 2026
ddon pushed a commit that referenced this pull request Aug 9, 2026
Post-merge review of #693 (slug generation moved onto locale_slug). The
change is right and the diagnosis behind it is right — I restored the
pre-PR implementation and ran both over ~40 inputs; every claim checks out
and the old code was worse than the PR says (`Café` slugged to `caf`,
`Ünïcödé Tëst` to `n-c-d-t-st`). Three findings, all documentation or
dependency hygiene rather than logic.

- locale_slug was pinned `~> 0.1`, which admits every 0.x. The exposure is
  not the API but the OUTPUT: a revised romanization table changes the slug
  a host derives from the same title, and slugs are persisted in URLs and
  compared for uniqueness. That is a content change arriving as a dependency
  bump. Pinned `~> 0.1.0`; mix.lock is unchanged.
- transliterate/1's docstring claims "Contract preserved exactly" while the
  new implementation lower-cases. The punctuation example it gives IS
  preserved (verified: `ülo.kask` -> `ulo.kask`), but `MiXeD CaSe` now comes
  back `mixed case`. Core's only caller downcases first; external callers do
  not necessarily.
- "Existing URLs are unaffected" holds for STORED slugs only. A caller that
  re-derives a slug from a title to find a row now derives a different
  string, and not only for Cyrillic content.

Tests: accented-Latin default change, ASCII-only output invariant over path
traversal / markup / emoji / zero-width / CJK, punctuation-and-spacing
survival, and the lower-casing. 15 tests, 0 failures — this file needs no
database, so it genuinely ran.

Review: dev_docs/pull_requests/2026/693-move-slug-generation-onto-locale-slug/CLAUDE_REVIEW.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants