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

@@ -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 ───────────────────────────────────────────────────────────