From ae9b69287ff09d96c54d2d1e30b13b2f3f49e958 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 03:56:17 +0800 Subject: [PATCH] fix(i18n): encode exact link paths safely --- scripts/translation-links.spec.ts | 10 +++++----- scripts/translation-links.ts | 8 +++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/translation-links.spec.ts b/scripts/translation-links.spec.ts index 1f7d41fc14..019b52f76f 100644 --- a/scripts/translation-links.spec.ts +++ b/scripts/translation-links.spec.ts @@ -27,8 +27,8 @@ function fixture(): string { writeFileSync(join(root, 'docs/guide.zh.md'), '# 指南\n') writeFileSync(join(root, 'docs/reference.md'), '# Overview\n') writeFileSync(join(root, 'docs/reference.zh.md'), '# 概览\n') - writeFileSync(join(root, 'docs/a#b.md'), '# Reserved\n') - writeFileSync(join(root, 'docs/a#b.zh.md'), '# 保留字符\n') + writeFileSync(join(root, 'docs/a)#?b.md'), '# Reserved\n') + writeFileSync(join(root, 'docs/a)#?b.zh.md'), '# 保留字符\n') writeFileSync(join(root, 'docs/unpaired.md'), '# Only\n') writeFileSync(join(root, 'docs/section/index.md'), '# Section\n') writeFileSync(join(root, 'docs/section/index.zh.md'), '# 章节\n') @@ -87,11 +87,11 @@ describe('translation link locale validation', () => { }) }) - it('keeps URL-reserved filename bytes escaped in an encoded exact path', () => { + it('encodes each exact path segment with only RFC 3986 unreserved characters', () => { const root = fixture() - const input = '[保留](a%23b%2Emd?view=full#section)\n' + const input = '[保留](a%29%23%3Fb%2Emd?view=full#section)\n' expect(rewriteTranslationLinkLocales(input, linkContext(root, 'docs/guide.zh.md'))).toEqual({ - content: '[保留](a%23b.zh.md?view=full#section)\n', + content: '[保留](a%29%23%3Fb.zh.md?view=full#section)\n', rewritten: 1, }) }) diff --git a/scripts/translation-links.ts b/scripts/translation-links.ts index f28025ab7b..af7c991fd4 100644 --- a/scripts/translation-links.ts +++ b/scripts/translation-links.ts @@ -143,13 +143,19 @@ function translationPairTarget(targetPath: string, context: TranslationLinkConte return { source, zh } } +function encodePathSegment(segment: string): string { + return encodeURIComponent(segment).replace(/[!'()*]/g, character => ( + `%${character.charCodeAt(0).toString(16).toUpperCase()}` + )) +} + function relativeExpectedPath( context: TranslationLinkContext, expectedPath: string, rawPath: string, ): string { const relative = posix.relative(posix.dirname(context.sourcePath), expectedPath) - const encoded = relative.split('/').map(segment => encodeURIComponent(segment)).join('/') + const encoded = relative.split('/').map(encodePathSegment).join('/') return rawPath.startsWith('./') && !encoded.startsWith('.') ? `./${encoded}` : encoded }