fix(#2848): non-Latin titles no longer produce empty slugs (Cyrillic transliteration) (#2934)

* test(#2848): add failing-first regression for non-Latin slug transliteration

generateSlugInternal and slugify both strip non-ASCII chars with no
transliteration step, so an all-Cyrillic title reduces to an empty slug.
12-row matrix: Cyrillic regression (both impls), Latin negative control,
multi-letter mappings, soft/hard sign drops, Ukrainian extras, null
contract, CJK unaffected, mixed scripts, truncation parity, slugify's
distinct no-truncate contract.

* fix(#2848): transliterate Cyrillic titles to ASCII before slug strip

Both generateSlugInternal (src/core-utils.cts) and slugify
(src/gsd2-import.cts) stripped non-ASCII with no transliteration, so an
all-Cyrillic title reduced to an empty slug, producing unnamed phase
directories (01-) and empty milestone_slug init JSON.

Add a shared transliterateForSlug primitive (core-utils) covering Russian
+ the reported Ukrainian/Belarusian extras (і ї є ґ ў), with multi-letter
mappings (ж→zh ч→ch ш→sh щ→sch ю→yu я→ya) and dropped soft/hard signs
(ъ ь). It runs BEFORE the existing ASCII filter, so Latin-script text
hits zero map entries and is byte-for-byte unchanged (negative control).
slugify consumes the shared primitive, preserving its distinct single
hyphen-strip + no-truncation contract. CJK/unmapped scripts keep the
existing strip-to-ASCII behavior.

Also corrects two test assertions to match the chosen й→y mapping and the
б→b (not bie) transliteration.

* changeset(#2848): Fixed — non-Latin slug transliteration

* changeset(#2848): backfill PR number 2934

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-07-31 10:56:11 -04:00
committed by GitHub
parent 81eeb8a53a
commit d2d2f7c088
5 changed files with 163 additions and 2 deletions

View File

@@ -95,7 +95,56 @@ function pathExistsInternal(cwd: string, targetPath: string): boolean {
function generateSlugInternal(text: string | null | undefined): string | null {
if (!text) return null;
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
return transliterateForSlug(text).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
}
// ─── Transliteration (#2848) ─────────────────────────────────────────────────
//
// Non-Latin titles used to reduce to an empty slug: the `[^a-z0-9]+` strip
// removed every character of an all-Cyrillic title and the hyphen cleanup left
// "". Callers then created unnamed phase directories (`01-`) and empty
// `milestone_slug` init JSON. The fix transliterates Cyrillic to ASCII BEFORE
// the existing ASCII filter, so a non-Latin title yields a usable ASCII slug
// while Latin-script text (which hits zero map entries) is byte-for-byte
// unchanged — the negative control is satisfied by construction.
//
// Multi-letter mappings (ж→zh, ч→ch, ш→sh, щ→sch, ю→yu, я→ya) are applied as a
// single pass; soft/hard signs (ъ, ь) drop to nothing rather than a hyphen.
// Scope is Cyrillic (Russian + the reported Ukrainian/Belarusian extras
// і ї є ґ ў) per the issue's confirmed-working patch. CJK and other
// non-transliterated scripts keep the existing strip-to-ASCII behavior.
const CYRILLIC_TRANSLITERATION: Readonly<Record<string, string>> = {
// multi-letter first (longest-match-safe within a single pass via ordered keys)
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh',
з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o',
п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts',
ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu',
я: 'ya',
// Ukrainian / Belarusian extras reported in #2848
є: 'ye', і: 'i', ї: 'yi', ґ: 'g', ў: 'u',
};
const CYRILLIC_TRANSLITERATION_KEYS = Object.keys(CYRILLIC_TRANSLITERATION);
/**
* Lowercase + transliterate Cyrillic characters to ASCII. The output still
* contains non-ASCII for scripts outside the map (CJK, etc.) — the caller's
* existing `[^a-z0-9]+` filter handles those. Latin-script input is returned
* lowercased with no other change.
*
* Shared by `generateSlugInternal` (core-utils) and `slugify` (gsd2-import) so
* the transliteration step is not duplicated across the two slug helpers (#2848
* explicitly requires both be fixed).
*/
function transliterateForSlug(text: string): string {
const lowered = text.toLowerCase();
let out = '';
for (const ch of lowered) {
out += CYRILLIC_TRANSLITERATION_KEYS.includes(ch)
? CYRILLIC_TRANSLITERATION[ch]
: ch;
}
return out;
}
// ─── Phase file helpers ──────────────────────────────────────────────────────
@@ -248,6 +297,7 @@ export = {
extractOneLinerFromBody,
pathExistsInternal,
generateSlugInternal,
transliterateForSlug,
filterPlanFiles,
filterSummaryFiles,
getPhaseFileStats,

View File

@@ -24,9 +24,12 @@ import path from 'node:path';
import { platformWriteSync } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { realClock } from './clock.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
import coreUtilsMod = require('./core-utils.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output } = ioMod;
const { transliterateForSlug } = coreUtilsMod;
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -88,7 +91,11 @@ function zeroPad(n: number, width = 2): string {
}
function slugify(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
// #2848: transliterate Cyrillic to ASCII before the filter so a non-Latin
// title does not collapse to an empty slug. The shared primitive keeps this
// in sync with generateSlugInternal. slugify's DISTINCT contract is preserved:
// single leading/trailing hyphen strip (/^-|-$/), and NO 60-char truncation.
return transliterateForSlug(title).replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
// ─── GSD-2 Parser ───────────────────────────────────────────────────────────