diff --git a/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts b/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts index 67b17213f2a4..77a683cf0bef 100644 --- a/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts @@ -74,9 +74,8 @@ describe('shortName', () => { 字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字: { apfs: '字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字', - // This is pretty bad (a character clipped in half), but I doubt if it - // ever happens - xfs: '字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字�', + // The character that doesn't fit whole is left out + xfs: '字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字字', }, }; const oldProcessPlatform = process.platform; @@ -117,6 +116,19 @@ describe('shortName', () => { expect(truncatedPathNonLatin.length).toBeLessThanOrEqual(255); }); + it('does not cut a character in half', () => { + const oldProcessPlatform = process.platform; + Object.defineProperty(process, 'platform', {value: 'darwin'}); + const emojiName = shortName(`a${'😀'.repeat(200)}`); + Object.defineProperty(process, 'platform', {value: 'android'}); + const hanName = shortName('字'.repeat(100)); + Object.defineProperty(process, 'platform', {value: oldProcessPlatform}); + expect(emojiName).toBe(`a${'😀'.repeat(121)}`); + expect(emojiName).not.toContain('\uFFFD'); + expect(hanName).toBe('字'.repeat(81)); + expect(hanName).not.toContain('\uFFFD'); + }); + it('does not truncate short paths', () => { const truncatedPath = shortName(SHORT_PATH); expect(truncatedPath).toEqual(SHORT_PATH); diff --git a/packages/docusaurus-utils/src/pathUtils.ts b/packages/docusaurus-utils/src/pathUtils.ts index e0862b609d5e..d71c91af20e6 100644 --- a/packages/docusaurus-utils/src/pathUtils.ts +++ b/packages/docusaurus-utils/src/pathUtils.ts @@ -29,20 +29,29 @@ export const isNameTooLong = (str: string): boolean => export function shortName(str: string): string { if (isMacOs() || isWindows()) { const overflowingChars = str.length - MAX_PATH_SEGMENT_CHARS; - return str.slice( - 0, - str.length - overflowingChars - SPACE_FOR_APPENDING - 1, - ); + let end = str.length - overflowingChars - SPACE_FOR_APPENDING - 1; + // Don't keep the first half of a surrogate pair (e.g. an emoji) + const lastCode = str.charCodeAt(end - 1); + if (end < str.length && lastCode >= 0xd800 && lastCode <= 0xdbff) { + end -= 1; + } + return str.slice(0, end); } const strBuffer = Buffer.from(str); const overflowingBytes = Buffer.byteLength(strBuffer) - MAX_PATH_SEGMENT_BYTES; - return strBuffer - .slice( - 0, - Buffer.byteLength(strBuffer) - overflowingBytes - SPACE_FOR_APPENDING - 1, - ) - .toString(); + let end = + Buffer.byteLength(strBuffer) - overflowingBytes - SPACE_FOR_APPENDING - 1; + // Don't cut a multi-byte UTF-8 character in half: back up while the first + // byte left out is a continuation byte (0b10xxxxxx) + while ( + end > 0 && + end < strBuffer.length && + (strBuffer[end]! & 0xc0) === 0x80 + ) { + end -= 1; + } + return strBuffer.subarray(0, end).toString(); } /**