diff --git a/packages/project/src/util/slugify.ts b/packages/project/src/util/slugify.ts index 4c28a71c8..9a2f531ac 100644 --- a/packages/project/src/util/slugify.ts +++ b/packages/project/src/util/slugify.ts @@ -1,5 +1,21 @@ +/** + * Convert a human-readable name into a slug used as an identifier. + * + * Any run of characters which is neither a letter, a digit nor an underscore + * becomes a separator. Letters and digits are matched with the Unicode + * properties `\p{L}` and `\p{N}` rather than `\w`, which is ASCII-only and + * would drop every non-Latin character: `café` slugified to `caf`, and a name + * written entirely in a non-Latin script collapsed to an empty string. + * + * For ASCII-only input this produces exactly the same slug as before, so + * identifiers in existing projects are unaffected. + */ export default function slugify(text: string) { return ( - text?.replace(/\W/g, ' ').trim().replace(/\s+/g, '-').toLowerCase() ?? '' + text + ?.replace(/[^\p{L}\p{N}_]+/gu, ' ') + .trim() + .replace(/\s+/g, '-') + .toLowerCase() ?? '' ); } diff --git a/packages/project/test/util/slugify.test.ts b/packages/project/test/util/slugify.test.ts new file mode 100644 index 000000000..119be158e --- /dev/null +++ b/packages/project/test/util/slugify.test.ts @@ -0,0 +1,42 @@ +import test from 'ava'; +import slugify from '../../src/util/slugify'; + +test('lowercases and hyphenates a plain name', (t) => { + t.is(slugify('My Workflow'), 'my-workflow'); +}); + +test('collapses runs of separators', (t) => { + t.is(slugify('My Workflow -- Two'), 'my-workflow-two'); +}); + +test('keeps underscores and digits', (t) => { + t.is(slugify('step_1 of 2'), 'step_1-of-2'); +}); + +test('trims leading and trailing separators', (t) => { + t.is(slugify(' ...My Workflow! '), 'my-workflow'); +}); + +test('keeps accented latin characters', (t) => { + t.is(slugify('café'), 'café'); + t.is(slugify('résumé'), 'résumé'); +}); + +test('keeps a whole accented phrase readable', (t) => { + t.is(slugify("Vérifier l'état du patient"), 'vérifier-l-état-du-patient'); +}); + +test('keeps non-latin scripts instead of collapsing to an empty string', (t) => { + t.is(slugify('患者確認'), '患者確認'); + t.is(slugify('проверка пациента'), 'проверка-пациента'); + t.is(slugify('التحقق من المريض'), 'التحقق-من-المريض'); +}); + +test('still drops characters which are neither letters nor digits', (t) => { + t.is(slugify('deploy 🚀 now'), 'deploy-now'); +}); + +test('handles empty and nullish input', (t) => { + t.is(slugify(''), ''); + t.is(slugify(undefined as unknown as string), ''); +}); diff --git a/packages/project/tsconfig.json b/packages/project/tsconfig.json index 77e4d2e8f..71bf585a7 100644 --- a/packages/project/tsconfig.json +++ b/packages/project/tsconfig.json @@ -3,6 +3,9 @@ "include": ["src/**/*.ts", "test/**/*test.ts"], "compilerOptions": { "module": "ESNext", + // Unicode property escapes in slugify need ES2018 or later. Build output is + // controlled by tsup; this only affects type validation. + "target": "ES2020", "sourceMap": true } }